From 782c892e2e8a83908cc121062f65303fd786d4c5 Mon Sep 17 00:00:00 2001
From: cong <3135055939@qq.com>
Date: Fri, 27 Mar 2026 21:26:01 +0800
Subject: [PATCH 1/8] luckmail
Conflicts:
src/web/routes/email.py
static/js/email_services.js
---
src/config/constants.py | 11 +
src/services/__init__.py | 3 +
src/services/freemail.py | 2 +-
src/services/luckmail_mail.py | 990 +++++++++++++++++++++++++++++++++
src/services/temp_mail.py | 2 +-
src/web/routes/accounts.py | 1 +
src/web/routes/email.py | 16 +
src/web/routes/payment.py | 3 +
src/web/routes/registration.py | 44 ++
static/js/app.js | 23 +
static/js/email_services.js | 57 +-
static/js/utils.js | 1 +
templates/accounts.html | 1 +
templates/email_services.html | 58 ++
templates/index.html | 1 +
15 files changed, 1205 insertions(+), 8 deletions(-)
create mode 100644 src/services/luckmail_mail.py
diff --git a/src/config/constants.py b/src/config/constants.py
index 7e9a7a38..d1daad50 100644
--- a/src/config/constants.py
+++ b/src/config/constants.py
@@ -40,6 +40,7 @@ class EmailServiceType(str, Enum):
FREEMAIL = "freemail"
IMAP_MAIL = "imap_mail"
CLOUDMAIL = "cloudmail"
+ LUCKMAIL = "luckmail"
# ============================================================================
@@ -149,6 +150,16 @@ class EmailServiceType(str, Enum):
"password": "",
"timeout": 30,
"max_retries": 3,
+ },
+ "luckmail": {
+ "base_url": "https://mails.luckyous.com/",
+ "api_key": "",
+ "project_code": "openai",
+ "email_type": "ms_graph",
+ "preferred_domain": "",
+ "timeout": 30,
+ "max_retries": 3,
+ "poll_interval": 3.0,
}
}
diff --git a/src/services/__init__.py b/src/services/__init__.py
index 2e6a19f2..e48b28f1 100644
--- a/src/services/__init__.py
+++ b/src/services/__init__.py
@@ -19,6 +19,7 @@
from .freemail import FreemailService
from .imap_mail import ImapMailService
from .cloudmail import CloudMailService
+from .luckmail_mail import LuckMailService
# 注册服务
EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService)
@@ -30,6 +31,7 @@
EmailServiceFactory.register(EmailServiceType.FREEMAIL, FreemailService)
EmailServiceFactory.register(EmailServiceType.IMAP_MAIL, ImapMailService)
EmailServiceFactory.register(EmailServiceType.CLOUDMAIL, CloudMailService)
+EmailServiceFactory.register(EmailServiceType.LUCKMAIL, LuckMailService)
# 导出 Outlook 模块的额外内容
from .outlook.base import (
@@ -65,6 +67,7 @@
'FreemailService',
'ImapMailService',
'CloudMailService',
+ 'LuckMailService',
# Outlook 模块
'ProviderType',
'EmailMessage',
diff --git a/src/services/freemail.py b/src/services/freemail.py
index e1e0587b..12d417c1 100644
--- a/src/services/freemail.py
+++ b/src/services/freemail.py
@@ -54,7 +54,7 @@ def __init__(self, config: Dict[str, Any] = None, name: str = None):
timeout=self.config["timeout"],
max_retries=self.config["max_retries"],
)
- self.http_client = HTTPClient(proxy_url=None, config=http_config)
+ self.http_client = HTTPClient(proxy_url=self.config.get("proxy_url"), config=http_config)
# 缓存 domain 列表
self._domains = []
diff --git a/src/services/luckmail_mail.py b/src/services/luckmail_mail.py
new file mode 100644
index 00000000..8c71cec0
--- /dev/null
+++ b/src/services/luckmail_mail.py
@@ -0,0 +1,990 @@
+"""
+LuckMail 邮箱服务实现
+"""
+
+import logging
+import json
+import re
+import sys
+import threading
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Set
+
+from .base import BaseEmailService, EmailServiceError, EmailServiceType
+from ..config.constants import OTP_CODE_PATTERN
+
+
+logger = logging.getLogger(__name__)
+_STATE_LOCK = threading.RLock()
+
+
+def _load_luckmail_client_class():
+ """
+ 兼容两种来源:
+ 1) 环境已安装 luckmail 包
+ 2) 本地 vendored 目录(优先 codex-console/luckmail,其次 ../tools/luckmail)
+ """
+ try:
+ from luckmail import LuckMailClient # type: ignore
+
+ return LuckMailClient
+ except Exception:
+ pass
+
+ candidates = [
+ Path(__file__).resolve().parents[2] / "luckmail",
+ Path(__file__).resolve().parents[3] / "tools" / "luckmail",
+ ]
+ for path in candidates:
+ if not path.is_dir():
+ continue
+ path_str = str(path)
+ if path_str not in sys.path:
+ sys.path.insert(0, path_str)
+ try:
+ from luckmail import LuckMailClient # type: ignore
+
+ return LuckMailClient
+ except Exception:
+ continue
+ return None
+
+
+class LuckMailService(BaseEmailService):
+ """LuckMail 接码邮箱服务"""
+
+ def __init__(self, config: Dict[str, Any] = None, name: str = None):
+ super().__init__(EmailServiceType.LUCKMAIL, name)
+
+ default_config = {
+ "base_url": "https://mails.luckyous.com/",
+ "api_key": "",
+ "project_code": "openai",
+ "email_type": "ms_graph",
+ "preferred_domain": "",
+ # purchase: 购买邮箱 + token 拉码(可多次)
+ # order: 创建接码订单 + 订单拉码(通常一次)
+ "inbox_mode": "purchase",
+ # 任务开始时优先复用“未在账号库且不在本地黑名单”的已购邮箱
+ "reuse_existing_purchases": True,
+ "purchase_scan_pages": 5,
+ "purchase_scan_page_size": 100,
+ "timeout": 30,
+ "max_retries": 3,
+ "poll_interval": 3.0,
+ "code_reuse_ttl": 600,
+ }
+ self.config = {**default_config, **(config or {})}
+
+ self.config["base_url"] = str(self.config.get("base_url") or "").strip()
+ if not self.config["base_url"]:
+ raise ValueError("LuckMail 配置缺少 base_url")
+ self.config["api_key"] = str(self.config.get("api_key") or "").strip()
+ self.config["project_code"] = str(self.config.get("project_code") or "openai").strip()
+ self.config["email_type"] = str(self.config.get("email_type") or "ms_graph").strip()
+ self.config["preferred_domain"] = str(self.config.get("preferred_domain") or "").strip().lstrip("@")
+ self.config["inbox_mode"] = self._normalize_inbox_mode(self.config.get("inbox_mode"))
+ self.config["reuse_existing_purchases"] = bool(self.config.get("reuse_existing_purchases", True))
+ self.config["purchase_scan_pages"] = max(int(self.config.get("purchase_scan_pages") or 5), 1)
+ self.config["purchase_scan_page_size"] = max(int(self.config.get("purchase_scan_page_size") or 100), 1)
+ self.config["poll_interval"] = float(self.config.get("poll_interval") or 3.0)
+ self.config["code_reuse_ttl"] = int(self.config.get("code_reuse_ttl") or 600)
+
+ if not self.config["api_key"]:
+ raise ValueError("LuckMail 配置缺少 api_key")
+ if not self.config["project_code"]:
+ raise ValueError("LuckMail 配置缺少 project_code")
+
+ client_cls = _load_luckmail_client_class()
+ if client_cls is None:
+ raise ValueError(
+ "未找到 LuckMail SDK,请先安装 luckmail 包或确保本地存在 tools/luckmail"
+ )
+
+ try:
+ self.client = client_cls(
+ base_url=self.config["base_url"],
+ api_key=self.config["api_key"],
+ )
+ except Exception as exc:
+ raise ValueError(f"初始化 LuckMail 客户端失败: {exc}")
+
+ self._orders_by_no: Dict[str, Dict[str, Any]] = {}
+ self._orders_by_email: Dict[str, Dict[str, Any]] = {}
+ # 记录每个订单/Token 最近返回过的验证码,避免后续阶段反复拿到旧码。
+ self._recent_codes_by_order: Dict[str, Dict[str, float]] = {}
+ self._data_dir = Path(__file__).resolve().parents[2] / "data"
+ self._registered_file = self._data_dir / "luckmail_registered_emails.json"
+ self._failed_file = self._data_dir / "luckmail_failed_emails.json"
+
+ def _normalize_inbox_mode(self, raw: Any) -> str:
+ mode = str(raw or "").strip().lower()
+ aliases = {
+ "purchase": "purchase",
+ "token": "purchase",
+ "buy": "purchase",
+ "purchased": "purchase",
+ "order": "order",
+ "code": "order",
+ }
+ return aliases.get(mode, "purchase")
+
+ def _extract_field(self, obj: Any, *keys: str) -> Any:
+ if obj is None:
+ return None
+ if isinstance(obj, dict):
+ for k in keys:
+ if k in obj:
+ return obj.get(k)
+ return None
+ for k in keys:
+ if hasattr(obj, k):
+ return getattr(obj, k)
+ return None
+
+ def _cache_order(self, info: Dict[str, Any]) -> None:
+ order_key = str(info.get("order_no") or info.get("service_id") or "").strip()
+ email = str(info.get("email") or "").strip().lower()
+ if order_key:
+ self._orders_by_no[order_key] = info
+ if email:
+ self._orders_by_email[email] = info
+
+ def _find_order(self, email: Optional[str], email_id: Optional[str]) -> Optional[Dict[str, Any]]:
+ if email_id:
+ item = self._orders_by_no.get(str(email_id).strip())
+ if item:
+ return item
+ if email:
+ item = self._orders_by_email.get(str(email).strip().lower())
+ if item:
+ return item
+ return None
+
+ def _is_recent_code(self, order_key: str, code: str, now: Optional[float] = None) -> bool:
+ if not order_key or not code:
+ return False
+ now_ts = now or time.time()
+ ttl = max(int(self.config.get("code_reuse_ttl") or 600), 0)
+ order_cache = self._recent_codes_by_order.get(order_key) or {}
+ if ttl <= 0:
+ return code in order_cache
+ used_at = order_cache.get(code)
+ if used_at is None:
+ return False
+ return (now_ts - used_at) <= ttl
+
+ def _remember_code(self, order_key: str, code: str, now: Optional[float] = None) -> None:
+ if not order_key or not code:
+ return
+ now_ts = now or time.time()
+ ttl = max(int(self.config.get("code_reuse_ttl") or 600), 0)
+ order_cache = self._recent_codes_by_order.setdefault(order_key, {})
+ order_cache[code] = now_ts
+ if ttl > 0:
+ expire_before = now_ts - ttl
+ stale = [k for k, v in order_cache.items() if v < expire_before]
+ for key in stale:
+ order_cache.pop(key, None)
+
+ def _now_iso(self) -> str:
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ def _normalize_email(self, email: Optional[str]) -> str:
+ return str(email or "").strip().lower()
+
+ def _is_resumable_failure_reason(self, reason: str) -> bool:
+ text = str(reason or "").strip().lower()
+ if not text:
+ return False
+ keywords = (
+ "该邮箱已存在 openai",
+ "邮箱已存在 openai",
+ "user_already_exists",
+ "already exists",
+ "创建用户账户失败",
+ )
+ return any(k in text for k in keywords)
+
+ def _extract_password_from_task_logs(self, logs_text: str) -> str:
+ if not logs_text:
+ return ""
+ matches = re.findall(r"生成密码[::]\s*([^\s]+)", str(logs_text))
+ if not matches:
+ return ""
+ return str(matches[-1] or "").strip()
+
+ def _recover_password_from_recent_task_logs(self, email: str, max_tasks: int = 30) -> str:
+ email_norm = self._normalize_email(email)
+ if not email_norm:
+ return ""
+ try:
+ from sqlalchemy import desc
+ from ..database.models import RegistrationTask as RegistrationTaskModel
+ from ..database.session import get_db
+
+ with get_db() as db:
+ tasks = (
+ db.query(RegistrationTaskModel)
+ .filter(RegistrationTaskModel.logs.isnot(None))
+ .order_by(desc(RegistrationTaskModel.created_at))
+ .limit(max_tasks)
+ .all()
+ )
+
+ for task in tasks:
+ logs_text = str(getattr(task, "logs", "") or "")
+ if email_norm not in logs_text.lower():
+ continue
+ recovered = self._extract_password_from_task_logs(logs_text)
+ if recovered:
+ return recovered
+ except Exception as exc:
+ logger.warning(f"LuckMail 从任务日志恢复密码失败: {exc}")
+ return ""
+
+ def _load_email_index(self, path: Path) -> Dict[str, Dict[str, Any]]:
+ with _STATE_LOCK:
+ try:
+ if not path.exists():
+ return {}
+ raw = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(raw, list):
+ return {
+ self._normalize_email(e): {"email": self._normalize_email(e), "updated_at": self._now_iso()}
+ for e in raw
+ if self._normalize_email(e)
+ }
+ if not isinstance(raw, dict):
+ return {}
+ payload = raw.get("emails", raw)
+ if isinstance(payload, list):
+ return {
+ self._normalize_email(e): {"email": self._normalize_email(e), "updated_at": self._now_iso()}
+ for e in payload
+ if self._normalize_email(e)
+ }
+ if isinstance(payload, dict):
+ result: Dict[str, Dict[str, Any]] = {}
+ for email_key, meta in payload.items():
+ email_norm = self._normalize_email(email_key)
+ if not email_norm:
+ continue
+ if isinstance(meta, dict):
+ record = meta.copy()
+ else:
+ record = {"value": meta}
+ record["email"] = email_norm
+ if "updated_at" not in record:
+ record["updated_at"] = self._now_iso()
+ result[email_norm] = record
+ return result
+ except Exception as exc:
+ logger.warning(f"LuckMail 读取状态文件失败: {path} - {exc}")
+ return {}
+ return {}
+
+ def _save_email_index(self, path: Path, index: Dict[str, Dict[str, Any]]) -> None:
+ with _STATE_LOCK:
+ try:
+ self._data_dir.mkdir(parents=True, exist_ok=True)
+ payload = {
+ "updated_at": self._now_iso(),
+ "count": len(index),
+ "emails": index,
+ }
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
+ tmp_path.write_text(
+ json.dumps(payload, ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+ tmp_path.replace(path)
+ except Exception as exc:
+ logger.warning(f"LuckMail 写入状态文件失败: {path} - {exc}")
+
+ def _mark_registered_email(self, email: str, extra: Optional[Dict[str, Any]] = None) -> None:
+ email_norm = self._normalize_email(email)
+ if not email_norm:
+ return
+ registered = self._load_email_index(self._registered_file)
+ failed = self._load_email_index(self._failed_file)
+ record = registered.get(email_norm, {"email": email_norm})
+ record["updated_at"] = self._now_iso()
+ if extra:
+ for k, v in extra.items():
+ if v is not None and v != "":
+ record[k] = v
+ registered[email_norm] = record
+ failed.pop(email_norm, None)
+ self._save_email_index(self._registered_file, registered)
+ self._save_email_index(self._failed_file, failed)
+
+ def _mark_failed_email(self, email: str, reason: str = "", extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ email_norm = self._normalize_email(email)
+ if not email_norm:
+ return {}
+ registered = self._load_email_index(self._registered_file)
+ if email_norm in registered:
+ return registered.get(email_norm) or {}
+ failed = self._load_email_index(self._failed_file)
+ record = failed.get(email_norm, {"email": email_norm, "fail_count": 0})
+ record["fail_count"] = int(record.get("fail_count") or 0) + 1
+ record["updated_at"] = self._now_iso()
+ if reason:
+ record["reason"] = reason[:500]
+ if extra:
+ for k, v in extra.items():
+ if v is not None and v != "":
+ record[k] = v
+ failed[email_norm] = record
+ self._save_email_index(self._failed_file, failed)
+ return record
+
+ def mark_registration_outcome(
+ self,
+ email: str,
+ success: bool,
+ reason: str = "",
+ context: Optional[Dict[str, Any]] = None,
+ ) -> None:
+ """供任务调度层调用:把注册结果落盘,避免后续重复尝试同邮箱。"""
+ if success:
+ self._mark_registered_email(email, extra=context)
+ else:
+ context_copy = dict(context or {})
+ password = str(context_copy.get("generated_password") or context_copy.get("password") or "").strip()
+ if password:
+ context_copy["password"] = password
+ record = self._mark_failed_email(email, reason=reason, extra=context_copy)
+ self._try_submit_appeal(email=email, reason=reason, context=context_copy, failed_record=record)
+
+ def _resolve_order_id_by_order_no(self, order_no: str, max_pages: int = 3, page_size: int = 50) -> Optional[int]:
+ order_no_text = str(order_no or "").strip()
+ if not order_no_text:
+ return None
+ try:
+ for page in range(1, max_pages + 1):
+ result = self.client.user.get_orders(page=page, page_size=page_size)
+ items = list(getattr(result, "list", []) or [])
+ if not items:
+ break
+ for item in items:
+ current_order_no = str(self._extract_field(item, "order_no") or "").strip()
+ if current_order_no != order_no_text:
+ continue
+ order_id_raw = self._extract_field(item, "id", "order_id")
+ if order_id_raw in (None, ""):
+ continue
+ try:
+ return int(order_id_raw)
+ except Exception:
+ continue
+ if len(items) < page_size:
+ break
+ except Exception as exc:
+ logger.warning(f"LuckMail 查询订单ID失败: {exc}")
+ return None
+
+ def _build_appeal_payload(
+ self,
+ reason: str,
+ context: Dict[str, Any],
+ ) -> Optional[Dict[str, Any]]:
+ reason_text = str(reason or "").strip()
+ reason_lower = reason_text.lower()
+
+ purchase_id_raw = context.get("purchase_id")
+ order_id_raw = context.get("order_id")
+ order_no = str(context.get("order_no") or "").strip()
+
+ appeal_type = None
+ order_id = None
+ purchase_id = None
+
+ if purchase_id_raw not in (None, ""):
+ try:
+ purchase_id = int(purchase_id_raw)
+ appeal_type = 2
+ except Exception:
+ purchase_id = None
+
+ if appeal_type is None and order_id_raw not in (None, ""):
+ try:
+ order_id = int(order_id_raw)
+ appeal_type = 1
+ except Exception:
+ order_id = None
+
+ if appeal_type is None and order_no:
+ order_id = self._resolve_order_id_by_order_no(order_no)
+ if order_id is not None:
+ appeal_type = 1
+
+ if appeal_type is None:
+ return None
+
+ if "429" in reason_lower or "limit" in reason_lower or "限流" in reason_text:
+ appeal_reason = "no_code"
+ elif "exists" in reason_lower or "already" in reason_lower or "已存在" in reason_text:
+ appeal_reason = "email_invalid"
+ elif "验证码" in reason_text or "otp" in reason_lower:
+ appeal_reason = "wrong_code"
+ else:
+ appeal_reason = "no_code"
+
+ desc = reason_text or "注册任务失败,申请人工核查并处理。"
+ payload: Dict[str, Any] = {
+ "appeal_type": appeal_type,
+ "reason": appeal_reason,
+ "description": desc[:300],
+ }
+ if appeal_type == 1 and order_id is not None:
+ payload["order_id"] = int(order_id)
+ if appeal_type == 2 and purchase_id is not None:
+ payload["purchase_id"] = int(purchase_id)
+ return payload
+
+ def _try_submit_appeal(
+ self,
+ email: str,
+ reason: str,
+ context: Dict[str, Any],
+ failed_record: Optional[Dict[str, Any]] = None,
+ ) -> None:
+ reason_text = str(reason or "").strip()
+ if not reason_text:
+ return
+
+ reason_lower = reason_text.lower()
+ should_appeal = (
+ "429" in reason_lower
+ or "限流" in reason_text
+ or "验证码" in reason_text
+ or "otp" in reason_lower
+ or "创建用户账户失败" in reason_text
+ or "该邮箱已存在 openai" in reason_lower
+ or "user_already_exists" in reason_lower
+ or "already exists" in reason_lower
+ )
+ if not should_appeal:
+ return
+
+ email_norm = self._normalize_email(email)
+ if not email_norm:
+ return
+
+ failed_index = self._load_email_index(self._failed_file)
+ current = failed_index.get(email_norm, {})
+ if not current and failed_record:
+ current = failed_record
+
+ # 申诉不代表删除:仅记录状态,不从 failed 名单移除。
+ last_appeal_status = str(current.get("appeal_status") or "").strip().lower()
+ if last_appeal_status == "submitted":
+ return
+
+ payload = self._build_appeal_payload(reason_text, context)
+ if not payload:
+ return
+
+ try:
+ response = self.client.user.create_appeal(**payload)
+ appeal_no = str(self._extract_field(response, "appeal_no") or "").strip()
+ current["appeal_status"] = "submitted"
+ current["appeal_at"] = self._now_iso()
+ if appeal_no:
+ current["appeal_no"] = appeal_no
+ failed_index[email_norm] = current
+ self._save_email_index(self._failed_file, failed_index)
+ logger.info(f"LuckMail 已提交申诉: email={email_norm}, appeal_no={appeal_no or '-'}")
+ except Exception as exc:
+ current["appeal_status"] = "failed"
+ current["appeal_error"] = str(exc)[:500]
+ current["appeal_at"] = self._now_iso()
+ failed_index[email_norm] = current
+ self._save_email_index(self._failed_file, failed_index)
+ logger.warning(f"LuckMail 提交申诉失败: email={email_norm}, error={exc}")
+
+ def _query_existing_account_emails(self, emails: Set[str]) -> Set[str]:
+ if not emails:
+ return set()
+ try:
+ from sqlalchemy import func
+ from ..database.models import Account as AccountModel
+ from ..database.session import get_db
+
+ normalized = [self._normalize_email(e) for e in emails if self._normalize_email(e)]
+ if not normalized:
+ return set()
+
+ with get_db() as db:
+ rows = (
+ db.query(func.lower(AccountModel.email))
+ .filter(func.lower(AccountModel.email).in_(normalized))
+ .all()
+ )
+ result = set()
+ for row in rows:
+ try:
+ value = row[0]
+ except Exception:
+ value = ""
+ email_norm = self._normalize_email(value)
+ if email_norm:
+ result.add(email_norm)
+ return result
+ except Exception as exc:
+ logger.warning(f"LuckMail 查询账号库邮箱失败: {exc}")
+ return set()
+
+ def _iter_purchase_items(self, scan_pages: int, page_size: int):
+ for page in range(1, scan_pages + 1):
+ try:
+ page_result = self.client.user.get_purchases(
+ page=page,
+ page_size=page_size,
+ user_disabled=0,
+ )
+ except Exception as exc:
+ logger.warning(f"LuckMail 拉取已购邮箱失败: page={page}, error={exc}")
+ break
+
+ items = list(getattr(page_result, "list", []) or [])
+ if not items:
+ break
+
+ for item in items:
+ yield item
+
+ if len(items) < page_size:
+ break
+
+ def _build_purchase_order_info(
+ self,
+ item: Any,
+ project_code: str,
+ email_type: str,
+ preferred_domain: str,
+ source: str,
+ ) -> Optional[Dict[str, Any]]:
+ email = self._normalize_email(self._extract_field(item, "email_address", "address", "email"))
+ token = str(self._extract_field(item, "token") or "").strip()
+ purchase_id_raw = self._extract_field(item, "id", "purchase_id")
+ purchase_id = str(purchase_id_raw).strip() if purchase_id_raw not in (None, "") else ""
+
+ if not email or not token:
+ return None
+
+ if preferred_domain:
+ domain = email.split("@", 1)[1] if "@" in email else ""
+ if domain != preferred_domain.lower():
+ return None
+
+ return {
+ "id": purchase_id or token,
+ "service_id": token,
+ "order_no": "",
+ "email": email,
+ "token": token,
+ "purchase_id": purchase_id or None,
+ "inbox_mode": "purchase",
+ "project_code": project_code,
+ "email_type": email_type,
+ "preferred_domain": preferred_domain,
+ "expired_at": "",
+ "created_at": time.time(),
+ "source": source,
+ }
+
+ def _pick_reusable_purchase_inbox(
+ self,
+ project_code: str,
+ email_type: str,
+ preferred_domain: str,
+ ) -> Optional[Dict[str, Any]]:
+ registered = self._load_email_index(self._registered_file)
+ failed = self._load_email_index(self._failed_file)
+
+ candidates: List[Dict[str, Any]] = []
+ for item in self._iter_purchase_items(
+ scan_pages=int(self.config.get("purchase_scan_pages") or 5),
+ page_size=int(self.config.get("purchase_scan_page_size") or 100),
+ ):
+ info = self._build_purchase_order_info(
+ item=item,
+ project_code=project_code,
+ email_type=email_type,
+ preferred_domain=preferred_domain,
+ source="reuse_purchase",
+ )
+ if not info:
+ continue
+ email = self._normalize_email(info.get("email"))
+ if not email:
+ continue
+ if email in registered:
+ continue
+ if email in failed:
+ failed_meta = failed.get(email) or {}
+ failed_reason = str(failed_meta.get("reason") or "")
+ if not self._is_resumable_failure_reason(failed_reason):
+ continue
+
+ resume_password = str(
+ failed_meta.get("password")
+ or failed_meta.get("generated_password")
+ or ""
+ ).strip()
+ if not resume_password:
+ resume_password = self._recover_password_from_recent_task_logs(email)
+ if not resume_password:
+ continue
+
+ info["resume_password"] = resume_password
+ info["source"] = "resume_failed"
+ candidates.append(info)
+
+ if not candidates:
+ return None
+
+ existing_in_db = self._query_existing_account_emails({self._normalize_email(c.get("email")) for c in candidates})
+ for info in candidates:
+ email = self._normalize_email(info.get("email"))
+ if email in existing_in_db:
+ self._mark_registered_email(
+ email,
+ extra={
+ "source": "accounts_db",
+ "token": info.get("token"),
+ "purchase_id": info.get("purchase_id"),
+ },
+ )
+ continue
+ return info
+ return None
+
+ def _create_order_inbox(
+ self,
+ project_code: str,
+ email_type: str,
+ preferred_domain: str,
+ specified_email: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ try:
+ kwargs: Dict[str, Any] = {
+ "project_code": project_code,
+ "email_type": email_type,
+ }
+ if preferred_domain:
+ kwargs["domain"] = preferred_domain
+ if specified_email:
+ kwargs["specified_email"] = specified_email
+ order = self.client.user.create_order(**kwargs)
+ except Exception as exc:
+ self.update_status(False, exc)
+ raise EmailServiceError(f"LuckMail 创建订单失败: {exc}")
+
+ order_no = str(self._extract_field(order, "order_no") or "").strip()
+ email = str(self._extract_field(order, "email_address", "email") or "").strip().lower()
+ if not order_no or not email:
+ raise EmailServiceError("LuckMail 返回订单信息不完整")
+
+ return {
+ "id": order_no,
+ "service_id": order_no,
+ "order_no": order_no,
+ "email": email,
+ "token": "",
+ "purchase_id": None,
+ "inbox_mode": "order",
+ "project_code": project_code,
+ "email_type": email_type,
+ "preferred_domain": preferred_domain,
+ "expired_at": str(self._extract_field(order, "expired_at") or "").strip(),
+ "created_at": time.time(),
+ "source": "new_order",
+ }
+
+ def _extract_first_purchase_item(self, purchased: Any) -> Any:
+ if purchased is None:
+ return None
+
+ if isinstance(purchased, list):
+ return purchased[0] if purchased else None
+
+ if isinstance(purchased, dict):
+ for key in ("purchases", "list", "items"):
+ arr = purchased.get(key)
+ if isinstance(arr, list) and arr:
+ return arr[0]
+ data = purchased.get("data")
+ if isinstance(data, dict):
+ for key in ("purchases", "list", "items"):
+ arr = data.get(key)
+ if isinstance(arr, list) and arr:
+ return arr[0]
+ return None
+
+ for key in ("purchases", "list", "items"):
+ arr = getattr(purchased, key, None)
+ if isinstance(arr, list) and arr:
+ return arr[0]
+
+ return None
+
+ def _create_purchase_inbox(
+ self,
+ project_code: str,
+ email_type: str,
+ preferred_domain: str,
+ ) -> Dict[str, Any]:
+ try:
+ kwargs: Dict[str, Any] = {
+ "project_code": project_code,
+ "quantity": 1,
+ "email_type": email_type,
+ }
+ if preferred_domain:
+ kwargs["domain"] = preferred_domain
+ purchased = self.client.user.purchase_emails(**kwargs)
+ except Exception as exc:
+ self.update_status(False, exc)
+ raise EmailServiceError(f"LuckMail 购买邮箱失败: {exc}")
+
+ item = self._extract_first_purchase_item(purchased)
+ if item is None:
+ raise EmailServiceError("LuckMail 购买邮箱返回为空")
+
+ email = str(self._extract_field(item, "email_address", "address", "email") or "").strip().lower()
+ token = str(self._extract_field(item, "token") or "").strip()
+ purchase_id_raw = self._extract_field(item, "id", "purchase_id")
+ purchase_id = str(purchase_id_raw).strip() if purchase_id_raw not in (None, "") else None
+
+ if not email or not token:
+ raise EmailServiceError("LuckMail 购买邮箱返回字段不完整(缺少 email/token)")
+
+ return {
+ "id": purchase_id or token,
+ "service_id": token,
+ "order_no": "",
+ "email": email,
+ "token": token,
+ "purchase_id": purchase_id,
+ "inbox_mode": "purchase",
+ "project_code": project_code,
+ "email_type": email_type,
+ "preferred_domain": preferred_domain,
+ "expired_at": "",
+ "created_at": time.time(),
+ "source": "new_purchase",
+ }
+
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
+ request_config = config or {}
+ project_code = str(request_config.get("project_code") or self.config["project_code"]).strip()
+ email_type = str(request_config.get("email_type") or self.config["email_type"]).strip()
+ preferred_domain = str(
+ request_config.get("preferred_domain")
+ or request_config.get("domain")
+ or self.config.get("preferred_domain")
+ or ""
+ ).strip().lstrip("@")
+
+ inbox_mode = self._normalize_inbox_mode(
+ request_config.get("inbox_mode") or request_config.get("mode") or self.config.get("inbox_mode")
+ )
+
+ if inbox_mode == "order":
+ order_info = self._create_order_inbox(
+ project_code=project_code,
+ email_type=email_type,
+ preferred_domain=preferred_domain,
+ )
+ else:
+ if bool(self.config.get("reuse_existing_purchases", True)):
+ reused = self._pick_reusable_purchase_inbox(
+ project_code=project_code,
+ email_type=email_type,
+ preferred_domain=preferred_domain,
+ )
+ if reused:
+ order_info = reused
+ else:
+ order_info = self._create_purchase_inbox(
+ project_code=project_code,
+ email_type=email_type,
+ preferred_domain=preferred_domain,
+ )
+ else:
+ order_info = self._create_purchase_inbox(
+ project_code=project_code,
+ email_type=email_type,
+ preferred_domain=preferred_domain,
+ )
+
+ self._cache_order(order_info)
+ self.update_status(True)
+ return order_info
+
+ def get_verification_code(
+ self,
+ email: str,
+ email_id: str = None,
+ timeout: int = 120,
+ pattern: str = OTP_CODE_PATTERN,
+ otp_sent_at: Optional[float] = None,
+ ) -> Optional[str]:
+ order_info = self._find_order(email=email, email_id=email_id)
+
+ token = ""
+ order_no = ""
+ inbox_mode = self._normalize_inbox_mode(self.config.get("inbox_mode"))
+ if order_info:
+ token = str(order_info.get("token") or "").strip()
+ order_no = str(order_info.get("order_no") or order_info.get("service_id") or "").strip()
+ inbox_mode = self._normalize_inbox_mode(order_info.get("inbox_mode") or inbox_mode)
+
+ if not token and email_id and str(email_id).strip().startswith("tok_"):
+ token = str(email_id).strip()
+ inbox_mode = "purchase"
+
+ if not order_no and email_id and not token:
+ order_no = str(email_id).strip()
+
+ if inbox_mode == "purchase":
+ if not token:
+ logger.warning(f"LuckMail 未找到 token,无法拉取验证码: email={email}, email_id={email_id}")
+ return None
+ code_key = f"token:{token}"
+ else:
+ if not order_no:
+ logger.warning(f"LuckMail 未找到订单号,无法拉取验证码: email={email}, email_id={email_id}")
+ return None
+ code_key = f"order:{order_no}"
+
+ poll_interval = float(self.config.get("poll_interval") or 3.0)
+ timeout_s = max(int(timeout or 120), 1)
+ deadline = time.time() + timeout_s
+ # OTP 刚发送后的短窗口内更容易读到旧码;配合“最近已用验证码”一起过滤。
+ otp_guard_until = (float(otp_sent_at) + 1.5) if otp_sent_at else None
+
+ while time.time() < deadline:
+ try:
+ if inbox_mode == "purchase":
+ result = self.client.user.get_token_code(token)
+ status = "success" if bool(self._extract_field(result, "has_new_mail")) else "pending"
+ else:
+ result = self.client.user.get_order_code(order_no)
+ status = str(self._extract_field(result, "status") or "").strip().lower()
+ except Exception as exc:
+ logger.warning(f"LuckMail 拉取验证码失败: {exc}")
+ self.update_status(False, exc)
+ time.sleep(min(poll_interval, 1.0))
+ continue
+
+ code = str(self._extract_field(result, "verification_code") or "").strip()
+
+ # token 模式下,部分平台会在 has_new_mail=false 时也返回最近一次 code。
+ # 这里以 code 为准,再配合“最近已用验证码”过滤旧码。
+ if inbox_mode == "purchase" and code:
+ status = "success"
+
+ if status in ("timeout", "cancelled"):
+ ref = token if inbox_mode == "purchase" else order_no
+ logger.info(f"LuckMail 未拿到验证码: {ref}, status={status}")
+ return None
+
+ if status == "success" and code:
+ if pattern and not re.search(pattern, code):
+ logger.warning(f"LuckMail 返回验证码格式不匹配: {code}")
+ return None
+
+ now_ts = time.time()
+ if otp_guard_until and now_ts < otp_guard_until and self._is_recent_code(code_key, code, now_ts):
+ time.sleep(poll_interval)
+ continue
+
+ if self._is_recent_code(code_key, code, now_ts):
+ # 同一 token/订单在不同流程阶段会复用查询接口,这里阻断旧码重复返回。
+ time.sleep(poll_interval)
+ continue
+
+ self._remember_code(code_key, code, now_ts)
+ self.update_status(True)
+ return code
+
+ time.sleep(poll_interval)
+
+ return None
+
+ def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
+ _ = kwargs
+ return list(self._orders_by_no.values())
+
+ def delete_email(self, email_id: str) -> bool:
+ order_info = self._find_order(email=email_id, email_id=email_id)
+ token = str((order_info or {}).get("token") or "").strip()
+ purchase_id = str((order_info or {}).get("purchase_id") or "").strip()
+ order_no = str((order_info or {}).get("order_no") or "").strip()
+
+ if not token and not order_no:
+ raw_id = str(email_id or "").strip()
+ if raw_id.startswith("tok_"):
+ token = raw_id
+ else:
+ order_no = raw_id
+
+ if not token and not order_no:
+ return False
+
+ try:
+ if token and purchase_id.isdigit():
+ # 购买邮箱通常不支持直接删除,标记禁用即可。
+ try:
+ self.client.user.set_purchase_disabled(int(purchase_id), 1)
+ except Exception:
+ pass
+ elif order_no:
+ self.client.user.cancel_order(order_no)
+
+ key = token or order_no
+ item = self._orders_by_no.pop(key, None)
+ if item:
+ email = str(item.get("email") or "").strip().lower()
+ if email:
+ self._orders_by_email.pop(email, None)
+ if token:
+ self._recent_codes_by_order.pop(f"token:{token}", None)
+ if order_no:
+ self._recent_codes_by_order.pop(f"order:{order_no}", None)
+ self.update_status(True)
+ return True
+ except Exception as exc:
+ logger.warning(f"LuckMail 删除邮箱失败: {exc}")
+ self.update_status(False, exc)
+ return False
+
+ def check_health(self) -> bool:
+ try:
+ self.client.user.get_balance()
+ self.update_status(True)
+ return True
+ except Exception as exc:
+ logger.warning(f"LuckMail 健康检查失败: {exc}")
+ self.update_status(False, exc)
+ return False
+
+ def get_service_info(self) -> Dict[str, Any]:
+ return {
+ "service_type": self.service_type.value,
+ "name": self.name,
+ "base_url": self.config.get("base_url"),
+ "project_code": self.config.get("project_code"),
+ "email_type": self.config.get("email_type"),
+ "preferred_domain": self.config.get("preferred_domain"),
+ "inbox_mode": self.config.get("inbox_mode"),
+ "cached_orders": len(self._orders_by_no),
+ "status": self.status.value,
+ }
diff --git a/src/services/temp_mail.py b/src/services/temp_mail.py
index 62908a8c..4d341d73 100644
--- a/src/services/temp_mail.py
+++ b/src/services/temp_mail.py
@@ -67,7 +67,7 @@ def __init__(self, config: Dict[str, Any] = None, name: str = None):
timeout=self.config["timeout"],
max_retries=self.config["max_retries"],
)
- self.http_client = HTTPClient(proxy_url=None, config=http_config)
+ self.http_client = HTTPClient(proxy_url=self.config.get("proxy_url"), config=http_config)
# 邮箱缓存:email -> {jwt, address}
self._email_cache: Dict[str, Dict[str, Any]] = {}
diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py
index 4024d90e..152461e8 100644
--- a/src/web/routes/accounts.py
+++ b/src/web/routes/accounts.py
@@ -2279,6 +2279,7 @@ def _build_inbox_config(db, service_type, email: str) -> dict:
EST.FREEMAIL: "freemail",
EST.IMAP_MAIL: "imap_mail",
EST.OUTLOOK: "outlook",
+ EST.LUCKMAIL: "luckmail",
}
db_type = type_map.get(service_type)
if not db_type:
diff --git a/src/web/routes/email.py b/src/web/routes/email.py
index 8dd4ef1f..8be3da8a 100644
--- a/src/web/routes/email.py
+++ b/src/web/routes/email.py
@@ -188,6 +188,7 @@ async def get_email_services_stats():
'freemail_count': 0,
'imap_mail_count': 0,
'cloudmail_count': 0,
+ 'luckmail_count': 0,
'tempmail_available': tempmail_enabled or yyds_enabled,
'yyds_mail_available': yyds_enabled,
'enabled_count': enabled_count
@@ -210,6 +211,8 @@ async def get_email_services_stats():
stats['imap_mail_count'] = count
elif service_type == 'cloudmail':
stats['cloudmail_count'] = count
+ elif service_type == 'luckmail':
+ stats['luckmail_count'] = count
return stats
@@ -304,6 +307,19 @@ async def get_service_types():
{"name": "email", "label": "邮箱地址", "required": True},
{"name": "password", "label": "密码/授权码", "required": True, "secret": True},
]
+ },
+ {
+ "value": "luckmail",
+ "label": "LuckMail",
+ "description": "LuckMail 接码服务(下单 + 轮询验证码)",
+ "config_fields": [
+ {"name": "base_url", "label": "平台地址", "required": False, "default": "https://mails.luckyous.com/"},
+ {"name": "api_key", "label": "API Key", "required": True, "secret": True},
+ {"name": "project_code", "label": "项目编码", "required": False, "default": "openai"},
+ {"name": "email_type", "label": "邮箱类型", "required": False, "default": "ms_graph"},
+ {"name": "preferred_domain", "label": "优先域名", "required": False, "placeholder": "outlook.com"},
+ {"name": "poll_interval", "label": "轮询间隔(秒)", "required": False, "default": 3.0},
+ ]
}
]
}
diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py
index 17c30729..c511c362 100644
--- a/src/web/routes/payment.py
+++ b/src/web/routes/payment.py
@@ -746,6 +746,9 @@ def _normalize_email_service_config_for_session_bootstrap(
elif service_type == EmailServiceType.DUCK_MAIL:
if "domain" in normalized and "default_domain" not in normalized:
normalized["default_domain"] = normalized.pop("domain")
+ elif service_type == EmailServiceType.LUCKMAIL:
+ if "domain" in normalized and "preferred_domain" not in normalized:
+ normalized["preferred_domain"] = normalized.pop("domain")
# IMAP/Outlook 等可按需使用代理;Temp-Mail/Freemail 强制直连。
if proxy_url and "proxy_url" not in normalized and service_type not in (EmailServiceType.TEMP_MAIL, EmailServiceType.FREEMAIL):
diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py
index cb89f056..6acfc4ec 100644
--- a/src/web/routes/registration.py
+++ b/src/web/routes/registration.py
@@ -217,6 +217,9 @@ def _normalize_email_service_config(
elif service_type == EmailServiceType.DUCK_MAIL:
if 'domain' in normalized and 'default_domain' not in normalized:
normalized['default_domain'] = normalized.pop('domain')
+ elif service_type == EmailServiceType.LUCKMAIL:
+ if 'domain' in normalized and 'preferred_domain' not in normalized:
+ normalized['preferred_domain'] = normalized.pop('domain')
if proxy_url and 'proxy_url' not in normalized:
normalized['proxy_url'] = proxy_url
@@ -403,6 +406,22 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy:
logger.info(f"使用数据库 IMAP 邮箱服务: {db_service.name}")
else:
raise ValueError("没有可用的 IMAP 邮箱服务,请先在邮箱服务中添加")
+ elif service_type == EmailServiceType.LUCKMAIL:
+ from ...database.models import EmailService as EmailServiceModel
+
+ db_service = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == "luckmail",
+ EmailServiceModel.enabled == True
+ ).order_by(EmailServiceModel.priority.asc()).first()
+
+ if db_service and db_service.config:
+ config = _normalize_email_service_config(service_type, db_service.config, actual_proxy_url)
+ crud.update_registration_task(db, task_uuid, email_service_id=db_service.id)
+ logger.info(f"使用数据库 LuckMail 服务: {db_service.name}")
+ else:
+ config = _normalize_email_service_config(service_type, email_service_config or {}, actual_proxy_url)
+ if not config.get("api_key"):
+ raise ValueError("没有可用的 LuckMail 服务,请先在邮箱服务中添加并填写 API Key")
else:
config = email_service_config or {}
@@ -1175,6 +1194,11 @@ async def get_available_email_services():
"available": False,
"count": 0,
"services": []
+ },
+ "luckmail": {
+ "available": False,
+ "count": 0,
+ "services": []
}
}
@@ -1333,6 +1357,26 @@ async def get_available_email_services():
result["imap_mail"]["count"] = len(imap_mail_services)
result["imap_mail"]["available"] = len(imap_mail_services) > 0
+ luckmail_services = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == "luckmail",
+ EmailServiceModel.enabled == True
+ ).order_by(EmailServiceModel.priority.asc()).all()
+
+ for service in luckmail_services:
+ config = service.config or {}
+ result["luckmail"]["services"].append({
+ "id": service.id,
+ "name": service.name,
+ "type": "luckmail",
+ "project_code": config.get("project_code"),
+ "email_type": config.get("email_type"),
+ "preferred_domain": config.get("preferred_domain"),
+ "priority": service.priority
+ })
+
+ result["luckmail"]["count"] = len(luckmail_services)
+ result["luckmail"]["available"] = len(luckmail_services) > 0
+
return result
diff --git a/static/js/app.js b/static/js/app.js
index 8fd65b6d..bc6608f4 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -27,6 +27,7 @@ let availableServices = {
moe_mail: { available: false, services: [] },
temp_mail: { available: false, services: [] },
duck_mail: { available: false, services: [] },
+ luckmail: { available: false, services: [] },
freemail: { available: false, services: [] }
};
@@ -390,6 +391,23 @@ function updateEmailServiceOptions() {
select.appendChild(optgroup);
}
+ // LuckMail
+ if (availableServices.luckmail && availableServices.luckmail.available) {
+ const optgroup = document.createElement('optgroup');
+ optgroup.label = `✉️ LuckMail (${availableServices.luckmail.count} 个服务)`;
+
+ availableServices.luckmail.services.forEach(service => {
+ const option = document.createElement('option');
+ option.value = `luckmail:${service.id}`;
+ option.textContent = service.name + (service.preferred_domain ? ` (@${service.preferred_domain})` : '');
+ option.dataset.type = 'luckmail';
+ option.dataset.serviceId = service.id;
+ optgroup.appendChild(option);
+ });
+
+ select.appendChild(optgroup);
+ }
+
// Freemail
if (availableServices.freemail && availableServices.freemail.available) {
const optgroup = document.createElement('optgroup');
@@ -456,6 +474,11 @@ function handleServiceChange(e) {
if (service) {
addLog('info', `[系统] 已选择 DuckMail 服务: ${service.name}`);
}
+ } else if (type === 'luckmail') {
+ const service = availableServices.luckmail.services.find(s => s.id == id);
+ if (service) {
+ addLog('info', `[系统] 已选择 LuckMail 服务: ${service.name}`);
+ }
} else if (type === 'freemail') {
const service = availableServices.freemail.services.find(s => s.id == id);
if (service) {
diff --git a/static/js/email_services.js b/static/js/email_services.js
index 919f4012..180d5e6f 100644
--- a/static/js/email_services.js
+++ b/static/js/email_services.js
@@ -4,7 +4,7 @@
// 状态
let outlookServices = [];
-let customServices = []; // 合并 moe_mail + temp_mail + duck_mail + freemail + imap_mail
+let customServices = []; // 合并 moe_mail + temp_mail + duck_mail + luckmail + freemail + imap_mail
let selectedOutlook = new Set();
let selectedCustom = new Set();
@@ -58,6 +58,7 @@ const elements = {
addYydsMailFields: document.getElementById('add-yydsmail-fields'),
addTempmailFields: document.getElementById('add-tempmail-fields'),
addDuckmailFields: document.getElementById('add-duckmail-fields'),
+ addLuckmailFields: document.getElementById('add-luckmail-fields'),
addFreemailFields: document.getElementById('add-freemail-fields'),
addImapFields: document.getElementById('add-imap-fields'),
@@ -70,6 +71,7 @@ const elements = {
editYydsMailFields: document.getElementById('edit-yydsmail-fields'),
editTempmailFields: document.getElementById('edit-tempmail-fields'),
editDuckmailFields: document.getElementById('edit-duckmail-fields'),
+ editLuckmailFields: document.getElementById('edit-luckmail-fields'),
editFreemailFields: document.getElementById('edit-freemail-fields'),
editImapFields: document.getElementById('edit-imap-fields'),
editCustomTypeBadge: document.getElementById('edit-custom-type-badge'),
@@ -87,6 +89,7 @@ const CUSTOM_SUBTYPE_LABELS = {
moemail: '🔗 MoeMail(自定义域名 API)',
tempmail: '📮 TempMail(自部署 Cloudflare Worker)',
duckmail: '🦆 DuckMail(DuckMail API)',
+ luckmail: '✉️ LuckMail(接码平台)',
freemail: 'Freemail(自部署 Cloudflare Worker)',
imap: '📧 IMAP 邮箱(Gmail/QQ/163等)'
};
@@ -196,6 +199,7 @@ function switchAddSubType(subType) {
elements.addYydsMailFields.style.display = subType === 'yydsmail' ? '' : 'none';
elements.addTempmailFields.style.display = subType === 'tempmail' ? '' : 'none';
elements.addDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none';
+ elements.addLuckmailFields.style.display = subType === 'luckmail' ? '' : 'none';
elements.addFreemailFields.style.display = subType === 'freemail' ? '' : 'none';
elements.addImapFields.style.display = subType === 'imap' ? '' : 'none';
}
@@ -207,6 +211,7 @@ function switchEditSubType(subType) {
elements.editYydsMailFields.style.display = subType === 'yydsmail' ? '' : 'none';
elements.editTempmailFields.style.display = subType === 'tempmail' ? '' : 'none';
elements.editDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none';
+ elements.editLuckmailFields.style.display = subType === 'luckmail' ? '' : 'none';
elements.editFreemailFields.style.display = subType === 'freemail' ? '' : 'none';
elements.editImapFields.style.display = subType === 'imap' ? '' : 'none';
elements.editCustomTypeBadge.textContent = CUSTOM_SUBTYPE_LABELS[subType] || CUSTOM_SUBTYPE_LABELS.moemail;
@@ -217,7 +222,7 @@ async function loadStats() {
try {
const data = await api.get('/email-services/stats');
elements.outlookCount.textContent = data.outlook_count || 0;
- elements.customCount.textContent = (data.custom_count || 0) + (data.yyds_mail_count || 0) + (data.temp_mail_count || 0) + (data.duck_mail_count || 0) + (data.freemail_count || 0) + (data.imap_mail_count || 0);
+ elements.customCount.textContent = (data.custom_count || 0) + (data.yyds_mail_count || 0) + (data.temp_mail_count || 0) + (data.duck_mail_count || 0) + (data.luckmail_count || 0) + (data.freemail_count || 0) + (data.imap_mail_count || 0);
elements.tempmailStatus.textContent = data.tempmail_available ? '可用' : '不可用';
elements.totalEnabled.textContent = data.enabled_count || 0;
} catch (error) {
@@ -320,6 +325,9 @@ function getCustomServiceTypeBadge(subType) {
if (subType === 'duckmail') {
return 'DuckMail';
}
+ if (subType === 'luckmail') {
+ return 'LuckMail';
+ }
if (subType === 'freemail') {
return 'Freemail';
}
@@ -332,6 +340,14 @@ function getCustomServiceAddress(service) {
const emailAddr = service.config?.email || '';
return `${escapeHtml(host)}
diff --git a/templates/index.html b/templates/index.html
index 0cf09508..43a4bb35 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -311,6 +311,7 @@
📝 注册设置
+
From 64b584a5e8f56d6521f1de9e1ecfbffc52c58378 Mon Sep 17 00:00:00 2001
From: cong <3135055939@qq.com>
Date: Fri, 27 Mar 2026 22:28:42 +0800
Subject: [PATCH 2/8] =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=8C=81=E4=B9=85?=
=?UTF-8?q?=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/config/constants.py | 4 ++++
src/core/register.py | 4 ++++
src/web/routes/registration.py | 31 +++++++++++++++++++++++++++++++
3 files changed, 39 insertions(+)
diff --git a/src/config/constants.py b/src/config/constants.py
index d1daad50..2c296d13 100644
--- a/src/config/constants.py
+++ b/src/config/constants.py
@@ -157,6 +157,10 @@ class EmailServiceType(str, Enum):
"project_code": "openai",
"email_type": "ms_graph",
"preferred_domain": "",
+ "inbox_mode": "purchase",
+ "reuse_existing_purchases": True,
+ "purchase_scan_pages": 5,
+ "purchase_scan_page_size": 100,
"timeout": 30,
"max_retries": 3,
"poll_interval": 3.0,
diff --git a/src/core/register.py b/src/core/register.py
index f4a098c9..515c305d 100644
--- a/src/core/register.py
+++ b/src/core/register.py
@@ -376,6 +376,10 @@ def _create_email(self) -> bool:
if raw_email and raw_email != normalized_email:
self._log(f"邮箱规范化: {raw_email} -> {normalized_email}")
+ source = str(self.email_info.get("source") or "").strip().lower()
+ if source == "reuse_purchase":
+ self._log("命中已购未注册邮箱池,优先复用历史未注册邮箱")
+
self._log(f"邮箱已就位,地址新鲜出炉: {self.email}")
return True
diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py
index 6acfc4ec..5163d1e9 100644
--- a/src/web/routes/registration.py
+++ b/src/web/routes/registration.py
@@ -439,6 +439,16 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy:
# 执行注册
result = engine.run()
+ marker = getattr(email_service, "mark_registration_outcome", None)
+ marker_context = {}
+ try:
+ info = getattr(engine, "email_info", None) or {}
+ for key in ("service_id", "order_no", "token", "purchase_id", "source"):
+ value = info.get(key) if isinstance(info, dict) else None
+ if value not in (None, ""):
+ marker_context[key] = value
+ except Exception:
+ marker_context = {}
if result.success:
# 更新代理使用时间
@@ -447,6 +457,16 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy:
# 保存到数据库
engine.save_to_database(result)
+ if callable(marker) and result.email:
+ try:
+ marker(
+ email=result.email,
+ success=True,
+ context=marker_context,
+ )
+ except Exception as mark_err:
+ logger.warning(f"记录邮箱成功状态失败: {mark_err}")
+
# 自动上传到 CPA(可多服务)
if auto_upload_cpa:
try:
@@ -543,6 +563,17 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy:
logger.info(f"注册任务完成: {task_uuid}, 邮箱: {result.email}")
else:
+ if callable(marker) and result.email:
+ try:
+ marker(
+ email=result.email,
+ success=False,
+ reason=result.error_message or "",
+ context=marker_context,
+ )
+ except Exception as mark_err:
+ logger.warning(f"记录邮箱失败状态失败: {mark_err}")
+
# 更新任务状态为失败
crud.update_registration_task(
db, task_uuid,
From ce2aa5c01ba1bef86d20aa30ccd7e3266f7613a8 Mon Sep 17 00:00:00 2001
From: cong <3135055939@qq.com>
Date: Sun, 29 Mar 2026 17:59:47 +0800
Subject: [PATCH 3/8] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=B3=A8=E5=86=8C?=
=?UTF-8?q?=E5=9B=9E=E8=B0=83=E7=9B=B4=E8=BF=9E=E4=B8=8E=E4=BC=9A=E8=AF=9D?=
=?UTF-8?q?=E6=8A=93=E5=8F=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/core/openai/overview.py | 17 ++
src/core/openai/payment.py | 91 ++++---
src/core/register.py | 436 +++++++++++++++++++++++++++++-----
src/services/luckmail_mail.py | 67 +++++-
src/web/routes/accounts.py | 13 +-
5 files changed, 517 insertions(+), 107 deletions(-)
diff --git a/src/core/openai/overview.py b/src/core/openai/overview.py
index 640c2b20..fd16d68a 100644
--- a/src/core/openai/overview.py
+++ b/src/core/openai/overview.py
@@ -17,6 +17,14 @@
logger = logging.getLogger(__name__)
+
+class AccountDeactivatedError(RuntimeError):
+ """账号被停用/冻结(401 with deactivated message)。"""
+
+ def __init__(self, message: str, status_code: int = 401):
+ super().__init__(message)
+ self.status_code = status_code
+
_USAGE_ENDPOINTS: Tuple[Tuple[str, str, bool], ...] = (
# required=True: 核心稳定端点,失败计入 errors
("me", "https://chatgpt.com/backend-api/me", True),
@@ -160,6 +168,10 @@ def _request_json(url: str, headers: Dict[str, str], proxy: Optional[str]) -> Di
timeout=20,
impersonate="chrome110",
)
+ if resp.status_code == 401:
+ text = str(resp.text or "")
+ if "deactivated" in text.lower():
+ raise AccountDeactivatedError(f"account_deactivated: {text[:200]}")
resp.raise_for_status()
data = resp.json()
if isinstance(data, dict):
@@ -173,6 +185,9 @@ def _extract_http_status(exc: Exception) -> Optional[int]:
status = getattr(response, "status_code", None)
if isinstance(status, int):
return status
+ status = getattr(exc, "status_code", None)
+ if isinstance(status, int):
+ return status
match = re.search(r"HTTP Error\s+(\d{3})", str(exc))
if match:
try:
@@ -188,6 +203,8 @@ def _request_json_with_proxy_fallback(url: str, headers: Dict[str, str], proxy:
"""
try:
return _request_json(url, headers, proxy)
+ except AccountDeactivatedError:
+ raise
except Exception as proxy_exc:
if not proxy:
raise
diff --git a/src/core/openai/payment.py b/src/core/openai/payment.py
index 5abfeda4..7db6eede 100644
--- a/src/core/openai/payment.py
+++ b/src/core/openai/payment.py
@@ -15,7 +15,7 @@
from curl_cffi import requests as cffi_requests
from ...database.models import Account
-from .overview import fetch_codex_overview
+from .overview import fetch_codex_overview, AccountDeactivatedError
logger = logging.getLogger(__name__)
@@ -50,6 +50,29 @@ def _build_proxies(proxy: Optional[str]) -> Optional[dict]:
return None
+def _raise_if_deactivated(resp, source: str) -> None:
+ if resp is None:
+ return
+ if getattr(resp, "status_code", None) != 401:
+ return
+ text = str(getattr(resp, "text", "") or "")
+ if "deactivated" in text.lower():
+ raise AccountDeactivatedError(f"account_deactivated({source}): {text[:200]}")
+
+
+def _request_json_with_deactivated(url: str, headers: Dict[str, str], proxy: Optional[str], source: str) -> Dict[str, Any]:
+ resp = cffi_requests.get(
+ url,
+ headers=headers,
+ proxies=_build_proxies(proxy),
+ timeout=20,
+ impersonate="chrome110",
+ )
+ _raise_if_deactivated(resp, source)
+ resp.raise_for_status()
+ return resp.json() if resp.content else {}
+
+
def _is_connectivity_error(err: Any) -> bool:
text = str(err or "").strip().lower()
if not text:
@@ -1048,19 +1071,18 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str,
# 1) me 接口
try:
- resp = cffi_requests.get(
+ data = _request_json_with_deactivated(
"https://chatgpt.com/backend-api/me",
headers=headers,
- proxies=_build_proxies(proxy),
- timeout=20,
- impersonate="chrome110",
+ proxy=proxy,
+ source="me",
)
- resp.raise_for_status()
successful_sources.append("me")
- data = resp.json() if resp.content else {}
detected = _analyze_me_payload(data, "me")
if detected:
return detected
+ except AccountDeactivatedError as exc:
+ return _result("deactivated", "account_deactivated", "high", note=str(exc))
except Exception as exc:
errors.append(f"me: {exc}")
@@ -1069,16 +1091,13 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str,
try:
headers_no_scope = dict(headers)
headers_no_scope.pop("ChatGPT-Account-Id", None)
- resp_no_scope = cffi_requests.get(
+ data_no_scope = _request_json_with_deactivated(
"https://chatgpt.com/backend-api/me",
headers=headers_no_scope,
- proxies=_build_proxies(proxy),
- timeout=20,
- impersonate="chrome110",
+ proxy=proxy,
+ source="me_no_scope",
)
- resp_no_scope.raise_for_status()
successful_sources.append("me_no_scope")
- data_no_scope = resp_no_scope.json() if resp_no_scope.content else {}
detected = _analyze_me_payload(data_no_scope, "me.no_scope")
if detected:
logger.info(
@@ -1088,39 +1107,37 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str,
detected.get("confidence"),
)
return detected
+ except AccountDeactivatedError as exc:
+ return _result("deactivated", "account_deactivated", "high", note=str(exc))
except Exception as exc:
errors.append(f"me_no_scope: {exc}")
# 2) wham/usage(Cockpit-tools 同款核心)
try:
- usage_resp = cffi_requests.get(
+ usage_data = _request_json_with_deactivated(
"https://chatgpt.com/backend-api/wham/usage",
headers=headers,
- proxies=_build_proxies(proxy),
- timeout=20,
- impersonate="chrome110",
+ proxy=proxy,
+ source="wham_usage",
)
- usage_resp.raise_for_status()
successful_sources.append("wham_usage")
- usage_data = usage_resp.json() if usage_resp.content else {}
detected = _analyze_usage_payload(usage_data, "wham_usage")
if detected:
return detected
+ except AccountDeactivatedError as exc:
+ return _result("deactivated", "account_deactivated", "high", note=str(exc))
except Exception as exc:
errors.append(f"wham_usage: {exc}")
# 3) wham/accounts/check(Cockpit-tools 用于账号资料同步的官方口径)
try:
- account_check_resp = cffi_requests.get(
+ account_check_data = _request_json_with_deactivated(
ACCOUNT_CHECK_URL,
headers=headers,
- proxies=_build_proxies(proxy),
- timeout=20,
- impersonate="chrome110",
+ proxy=proxy,
+ source="wham_accounts_check",
)
- account_check_resp.raise_for_status()
successful_sources.append("wham_accounts_check")
- account_check_data = account_check_resp.json() if account_check_resp.content else {}
for raw in _collect_plan_candidates(account_check_data):
mapped = _map_plan_to_subscription(raw)
if mapped in ("plus", "team"):
@@ -1128,6 +1145,8 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str,
if mapped == "free":
weak_free_source = weak_free_source or "wham_accounts_check.plan"
explicit_free_value = explicit_free_value or str(raw)
+ except AccountDeactivatedError as exc:
+ return _result("deactivated", "account_deactivated", "high", note=str(exc))
except Exception as exc:
errors.append(f"wham_accounts_check: {exc}")
@@ -1136,16 +1155,13 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str,
headers_no_scope = dict(headers)
headers_no_scope.pop("ChatGPT-Account-Id", None)
try:
- usage_no_scope_resp = cffi_requests.get(
+ usage_no_scope_data = _request_json_with_deactivated(
"https://chatgpt.com/backend-api/wham/usage",
headers=headers_no_scope,
- proxies=_build_proxies(proxy),
- timeout=20,
- impersonate="chrome110",
+ proxy=proxy,
+ source="wham_usage_no_scope",
)
- usage_no_scope_resp.raise_for_status()
successful_sources.append("wham_usage_no_scope")
- usage_no_scope_data = usage_no_scope_resp.json() if usage_no_scope_resp.content else {}
detected = _analyze_usage_payload(usage_no_scope_data, "wham_usage.no_scope")
if detected:
logger.info(
@@ -1155,20 +1171,19 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str,
detected.get("confidence"),
)
return detected
+ except AccountDeactivatedError as exc:
+ return _result("deactivated", "account_deactivated", "high", note=str(exc))
except Exception as exc:
errors.append(f"wham_usage_no_scope: {exc}")
try:
- account_check_no_scope_resp = cffi_requests.get(
+ account_check_no_scope_data = _request_json_with_deactivated(
ACCOUNT_CHECK_URL,
headers=headers_no_scope,
- proxies=_build_proxies(proxy),
- timeout=20,
- impersonate="chrome110",
+ proxy=proxy,
+ source="wham_accounts_check_no_scope",
)
- account_check_no_scope_resp.raise_for_status()
successful_sources.append("wham_accounts_check_no_scope")
- account_check_no_scope_data = account_check_no_scope_resp.json() if account_check_no_scope_resp.content else {}
for raw in _collect_plan_candidates(account_check_no_scope_data):
mapped = _map_plan_to_subscription(raw)
if mapped in ("plus", "team"):
@@ -1176,6 +1191,8 @@ def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str,
if mapped == "free":
weak_free_source = weak_free_source or "wham_accounts_check.no_scope.plan"
explicit_free_value = explicit_free_value or str(raw)
+ except AccountDeactivatedError as exc:
+ return _result("deactivated", "account_deactivated", "high", note=str(exc))
except Exception as exc:
errors.append(f"wham_accounts_check_no_scope: {exc}")
diff --git a/src/core/register.py b/src/core/register.py
index 515c305d..92d8af86 100644
--- a/src/core/register.py
+++ b/src/core/register.py
@@ -10,6 +10,7 @@
import secrets
import string
import uuid
+import os
from typing import Optional, Dict, Any, Tuple, Callable, List
from dataclasses import dataclass
from datetime import datetime
@@ -36,6 +37,10 @@
logger = logging.getLogger(__name__)
+_DEFAULT_OAI_CLIENT_VERSION = os.getenv("OAI_CLIENT_VERSION", "prod-34ffa95763ddf2cc215bcd7545731a9818ca9a8b")
+_DEFAULT_OAI_CLIENT_BUILD = os.getenv("OAI_CLIENT_BUILD", "5583259")
+_DEFAULT_OAI_LANGUAGE = os.getenv("OAI_LANGUAGE", "zh-CN")
+
@dataclass
class RegistrationResult:
@@ -146,12 +151,18 @@ def __init__(
self._create_account_workspace_id: Optional[str] = None
self._create_account_account_id: Optional[str] = None
self._create_account_refresh_token: Optional[str] = None
+ self._create_account_callback_url: Optional[str] = None # create_account 返回的 OAuth callback(含 code)
+ self._create_account_page_type: Optional[str] = None
self._last_validate_otp_continue_url: Optional[str] = None
self._last_validate_otp_workspace_id: Optional[str] = None
self._last_register_password_error: Optional[str] = None
self._last_otp_validation_code: Optional[str] = None
self._last_otp_validation_status_code: Optional[int] = None
self._last_otp_validation_outcome: str = "" # success/http_non_200/network_timeout/network_error
+ self._oai_session_id: str = str(uuid.uuid4())
+ self._oai_client_version: str = _DEFAULT_OAI_CLIENT_VERSION
+ self._oai_client_build: str = _DEFAULT_OAI_CLIENT_BUILD
+ self._oai_language: str = _DEFAULT_OAI_LANGUAGE
def _log(self, message: str, level: str = "info"):
"""记录日志"""
@@ -579,6 +590,20 @@ def _submit_auth_start(
page_type = response_data.get("page", {}).get("type", "")
self._log(f"响应页面类型: {page_type}")
+ continue_url = str(response_data.get("continue_url") or "").strip()
+ if continue_url:
+ try:
+ self.session.get(
+ continue_url,
+ headers={
+ "referer": referer,
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ },
+ timeout=15,
+ )
+ except Exception as e:
+ self._log(f"{log_label}补跳 continue_url 失败: {e}", "warning")
+
is_existing = page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]
if is_existing:
@@ -712,6 +737,17 @@ def _submit_login_password(self) -> SignupFormResult:
is_existing = page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]
if is_existing:
self._otp_sent_at = time.time()
+ try:
+ self.session.get(
+ "https://auth.openai.com/email-verification",
+ headers={
+ "referer": "https://auth.openai.com/log-in/password",
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ },
+ timeout=15,
+ )
+ except Exception as e:
+ self._log(f"登录验证码页预热失败: {e}", "warning")
self._log("登录密码校验通过,等待系统自动发送的验证码")
return SignupFormResult(
@@ -742,6 +778,77 @@ def _reset_auth_flow(self) -> None:
self.session_token = None
self._otp_sent_at = None
+ def _get_device_id_for_headers(self) -> str:
+ did = str(self.device_id or "").strip()
+ if not did:
+ try:
+ did = str(self.session.cookies.get("oai-did") or "").strip()
+ except Exception:
+ did = ""
+ if not did:
+ did = str(uuid.uuid4())
+ try:
+ self.session.cookies.set("oai-did", did, domain=".chatgpt.com", path="/")
+ except Exception:
+ pass
+ self.device_id = did
+ return did
+
+ def _build_chatgpt_headers(
+ self,
+ referer: str = "https://chatgpt.com/",
+ access_token: Optional[str] = None,
+ ) -> Dict[str, str]:
+ did = self._get_device_id_for_headers()
+ headers = {
+ "accept": "application/json, text/plain, */*",
+ "accept-language": f"{self._oai_language},zh;q=0.9,en;q=0.8",
+ "origin": "https://chatgpt.com",
+ "referer": referer,
+ "user-agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
+ ),
+ "oai-client-build-number": self._oai_client_build,
+ "oai-client-version": self._oai_client_version,
+ "oai-device-id": did,
+ "oai-language": self._oai_language,
+ "oai-session-id": self._oai_session_id,
+ "priority": "u=1, i",
+ "sec-ch-ua": "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"",
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": "\"Windows\"",
+ "sec-fetch-dest": "empty",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-site": "same-origin",
+ }
+ if access_token:
+ headers["authorization"] = f"Bearer {access_token}"
+ return headers
+
+ def _touch_otp_continue_url(self, stage_label: str) -> None:
+ """登录 OTP 成功后,访问 continue_url 以落地授权 cookie(对齐副本脚本)。"""
+ try:
+ continue_url = str(self._last_validate_otp_continue_url or "").strip()
+ if not continue_url:
+ return
+ self._log(f"{stage_label}命中 continue_url,尝试补跳授权页...")
+ self.session.get(
+ continue_url,
+ headers={
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "referer": "https://auth.openai.com/email-verification",
+ "user-agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
+ ),
+ },
+ allow_redirects=True,
+ timeout=20,
+ )
+ except Exception as e:
+ self._log(f"{stage_label}补跳 continue_url 失败: {e}", "warning")
+
def _prepare_authorize_flow(self, label: str) -> Tuple[Optional[str], Optional[str]]:
"""初始化当前阶段的授权流程,返回 device id 和 sentinel token。"""
self._log(f"{label}: 先把会话热热身...")
@@ -803,14 +910,7 @@ def _warmup_chatgpt_session(self) -> None:
try:
self.session.get(
"https://chatgpt.com/",
- headers={
- "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
- "referer": "https://auth.openai.com/",
- "user-agent": (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
- "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
- ),
- },
+ headers=self._build_chatgpt_headers(referer="https://auth.openai.com/"),
timeout=20,
)
except Exception as e:
@@ -836,8 +936,14 @@ def _capture_auth_session_tokens(self, result: RegistrationResult, access_hint:
"cache-control": "no-cache",
"pragma": "no-cache",
}
- if access_token:
- headers["authorization"] = f"Bearer {access_token}"
+ headers = self._build_chatgpt_headers(
+ referer="https://chatgpt.com/",
+ access_token=access_token or None,
+ )
+ headers.update({
+ "cache-control": "no-cache",
+ "pragma": "no-cache",
+ })
response = self.session.get(
"https://chatgpt.com/api/auth/session",
headers=headers,
@@ -879,14 +985,10 @@ def _capture_auth_session_tokens(self, result: RegistrationResult, access_hint:
retry_response = self.session.get(
"https://chatgpt.com/api/auth/session",
headers={
- "accept": "application/json",
- "referer": "https://chatgpt.com/",
- "origin": "https://chatgpt.com",
- "user-agent": (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
- "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
+ **self._build_chatgpt_headers(
+ referer="https://chatgpt.com/",
+ access_token=access_token,
),
- "authorization": f"Bearer {access_token}",
"cache-control": "no-cache",
"pragma": "no-cache",
},
@@ -955,15 +1057,7 @@ def _bootstrap_chatgpt_signin_for_session(self, result: RegistrationResult) -> b
try:
csrf_resp = self.session.get(
"https://chatgpt.com/api/auth/csrf",
- headers={
- "accept": "application/json",
- "referer": "https://chatgpt.com/auth/login",
- "origin": "https://chatgpt.com",
- "user-agent": (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
- "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
- ),
- },
+ headers=self._build_chatgpt_headers(referer="https://chatgpt.com/auth/login"),
timeout=20,
)
if csrf_resp.status_code == 200:
@@ -981,14 +1075,8 @@ def _bootstrap_chatgpt_signin_for_session(self, result: RegistrationResult) -> b
signin_resp = self.session.post(
"https://chatgpt.com/api/auth/signin/openai",
headers={
- "accept": "application/json",
+ **self._build_chatgpt_headers(referer="https://chatgpt.com/auth/login"),
"content-type": "application/x-www-form-urlencoded",
- "origin": "https://chatgpt.com",
- "referer": "https://chatgpt.com/auth/login",
- "user-agent": (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
- "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
- ),
},
data={
"csrfToken": csrf_token,
@@ -1022,14 +1110,7 @@ def _bootstrap_chatgpt_signin_for_session(self, result: RegistrationResult) -> b
try:
self.session.get(
callback_url,
- headers={
- "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
- "referer": "https://chatgpt.com/auth/login",
- "user-agent": (
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
- "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
- ),
- },
+ headers=self._build_chatgpt_headers(referer="https://chatgpt.com/auth/login"),
allow_redirects=True,
timeout=25,
)
@@ -1184,11 +1265,7 @@ def _follow_chatgpt_auth_redirects(self, start_url: str) -> Tuple[str, str]:
resp = self.session.get(
current_url,
- headers={
- "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
- "referer": "https://chatgpt.com/",
- "user-agent": ua,
- },
+ headers=self._build_chatgpt_headers(referer="https://chatgpt.com/"),
timeout=25,
allow_redirects=False,
)
@@ -1221,11 +1298,7 @@ def _follow_chatgpt_auth_redirects(self, start_url: str) -> Tuple[str, str]:
try:
self.session.get(
"https://chatgpt.com/",
- headers={
- "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
- "referer": current_url,
- "user-agent": ua,
- },
+ headers=self._build_chatgpt_headers(referer=current_url),
timeout=20,
)
except Exception:
@@ -1583,6 +1656,11 @@ def _complete_token_exchange_outlook(self, result: RegistrationResult) -> bool:
result.error_message = "验证码校验失败"
return False
+ if self._create_account_callback_url and not self._is_existing_account:
+ self._log("检测到 create_account callback,尝试直接换取 token...")
+ if self._consume_create_account_callback(result):
+ return True
+
self._log("摸一下 Workspace ID,看看该坐哪桌...")
workspace_id = str(self._last_validate_otp_workspace_id or "").strip()
if workspace_id:
@@ -2036,8 +2114,7 @@ def _register_password(self, did: Optional[str] = None, sen_token: Optional[str]
OPENAI_PAGE_TYPES["LOGIN_PASSWORD"],
OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"],
):
- self._log("登录入口探测命中:该邮箱大概率已是 OpenAI 账号", "warning")
- self._mark_email_as_registered()
+ self._log("登录入口探测命中:该邮箱大概率已是 OpenAI 账号(按失败记录并走申诉)", "warning")
self._last_register_password_error = (
"该邮箱已存在 OpenAI 账号。"
"若是刚刚注册中断,请优先使用上一轮任务日志里的“生成密码”走登录续跑;"
@@ -2098,6 +2175,7 @@ def _send_verification_code(self, referer: Optional[str] = None) -> bool:
)
self._log(f"验证码发送状态: {response.status_code}")
+ self._log(f"验证码发送信息: {response.text}")
return response.status_code == 200
except Exception as e:
@@ -2213,7 +2291,7 @@ def _validate_verification_code(self, code: str) -> bool:
else:
self._last_otp_validation_outcome = "network_error"
self._log(f"验证验证码失败: {e}", "error")
- return False
+ return False
def _verify_email_otp_with_retry(
self,
@@ -2277,6 +2355,12 @@ def _verify_email_otp_with_retry(
attempted_codes.add(code)
if self._validate_verification_code(code):
+ if stage_label and (
+ "登录验证码" in stage_label
+ or "login" in stage_label.lower()
+ or "会话桥接" in stage_label
+ ):
+ self._touch_otp_continue_url(stage_label)
return True
if attempt < max_attempts:
@@ -2313,10 +2397,54 @@ def _create_user_account(self) -> bool:
try:
data = response.json() or {}
+ response_text = ""
+ try:
+ response_text = str(response.text or "")
+ except Exception:
+ response_text = ""
continue_url = str(data.get("continue_url") or "").strip()
if continue_url:
self._create_account_continue_url = continue_url
self._log(f"create_account 返回 continue_url,已缓存: {continue_url[:100]}...")
+ if "/api/auth/callback/openai" in continue_url and "code=" in continue_url:
+ self._create_account_callback_url = continue_url
+ self._log("create_account 返回 OAuth callback(含 code),将优先走直连会话交换")
+ page_info = data.get("page") if isinstance(data, dict) else None
+ if isinstance(page_info, dict):
+ page_type = str(page_info.get("type") or "").strip()
+ if page_type:
+ self._create_account_page_type = page_type
+ self._log(f"create_account 返回 page.type: {page_type}")
+ page_url = str(
+ page_info.get("url")
+ or page_info.get("href")
+ or page_info.get("external_url")
+ or ""
+ ).strip()
+ if page_url:
+ self._log(f"create_account 返回 page.url: {page_url[:100]}...")
+ if "/api/auth/callback/openai" in page_url and "code=" in page_url:
+ self._create_account_callback_url = page_url
+ self._log("create_account 返回 OAuth callback(page.url),将优先走直连会话交换")
+
+ if not self._create_account_callback_url:
+ # 兜底:从原始响应中提取 callback
+ try:
+ haystacks = [response_text, json.dumps(data, ensure_ascii=False)]
+ for raw in haystacks:
+ if not raw:
+ continue
+ match = re.search(
+ r"https://chatgpt\.com/api/auth/callback/openai\?[^\"'\\s>]+",
+ raw,
+ re.IGNORECASE,
+ )
+ if match:
+ self._create_account_callback_url = match.group(0)
+ self._log("create_account 响应体命中 OAuth callback,已缓存")
+ break
+ except Exception:
+ pass
account_id = str(
data.get("account_id")
or data.get("chatgpt_account_id")
@@ -2350,6 +2478,93 @@ def _create_user_account(self) -> bool:
self._log(f"创建账户失败: {e}", "error")
return False
+ def _decode_jwt_payload(self, token: str) -> Dict[str, Any]:
+ """解析 JWT payload(不验证签名)。"""
+ try:
+ raw = str(token or "").strip()
+ if raw.count(".") < 2:
+ return {}
+ payload = raw.split(".")[1]
+ import base64
+ pad = "=" * ((4 - (len(payload) % 4)) % 4)
+ decoded = base64.urlsafe_b64decode((payload + pad).encode("ascii"))
+ data = json.loads(decoded.decode("utf-8"))
+ return data if isinstance(data, dict) else {}
+ except Exception:
+ return {}
+
+ def _consume_create_account_callback(self, result: RegistrationResult) -> bool:
+ """
+ create_account 已返回 chatgpt.com/api/auth/callback/openai?code=... 时,
+ 直接完成 code exchange + auth/session 抓取,跳过二次登录。
+ """
+ callback_url = str(self._create_account_callback_url or "").strip()
+ if not callback_url:
+ return False
+
+ self._log("create_account 已返回 OAuth callback,尝试直连完成会话交换...")
+ try:
+ self._warmup_chatgpt_session()
+ resp = self.session.get(
+ callback_url,
+ headers={
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "referer": "https://chatgpt.com/",
+ "user-agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
+ ),
+ },
+ allow_redirects=True,
+ timeout=25,
+ )
+ self._log(f"create_account callback 状态: {resp.status_code}")
+ except Exception as e:
+ self._log(f"create_account callback 请求失败: {e}", "warning")
+ return False
+
+ self._warmup_chatgpt_session()
+ captured = self._capture_auth_session_tokens(result)
+ if not captured:
+ self._log("callback 后 auth/session 未完全命中,尝试轻量补抓 access_token...", "warning")
+ self._capture_access_token_light(result)
+ if not result.session_token:
+ result.session_token = self._extract_session_token_from_cookie_text(self._dump_session_cookies())
+
+ if not result.account_id and result.access_token:
+ result.account_id = self._extract_account_id_from_access_token(result.access_token)
+ if not result.workspace_id:
+ workspace_id = str(self._get_workspace_id() or "").strip()
+ if workspace_id:
+ result.workspace_id = workspace_id
+ elif self._create_account_workspace_id:
+ result.workspace_id = str(self._create_account_workspace_id or "").strip()
+ if not result.refresh_token and self._create_account_refresh_token:
+ result.refresh_token = str(self._create_account_refresh_token or "").strip()
+
+ if result.access_token:
+ claims = self._decode_jwt_payload(result.access_token)
+ if claims:
+ auth_claim = claims.get("https://api.openai.com/auth") or {}
+ email_claim = str(claims.get("email") or auth_claim.get("email") or "").strip()
+ exp_claim = claims.get("exp")
+ account_claim = str(
+ auth_claim.get("chatgpt_account_id") or claims.get("chatgpt_account_id") or ""
+ ).strip()
+ if email_claim:
+ self._log(f"access_token 解析: email={email_claim}")
+ if not result.email:
+ result.email = email_claim
+ if account_claim and not result.account_id:
+ result.account_id = account_claim
+ if exp_claim:
+ self._log(f"access_token 解析: exp={exp_claim}")
+
+ result.password = self.password or ""
+ result.source = "register"
+ result.device_id = result.device_id or str(self.device_id or "")
+ return bool(result.access_token)
+
def _get_workspace_id(self) -> Optional[str]:
"""获取 Workspace ID"""
try:
@@ -2369,7 +2584,16 @@ def _extract_workspace_id(payload: Any) -> str:
return str((workspaces[0] or {}).get("id") or "").strip()
return ""
- auth_cookie = str(self.session.cookies.get("oai-client-auth-session") or "").strip()
+ auth_cookie = ""
+ try:
+ auth_cookie = str(
+ self.session.cookies.get("oai-client-auth-session", domain=".auth.openai.com")
+ or self.session.cookies.get("oai-client-auth-session", domain="auth.openai.com")
+ or self.session.cookies.get("oai-client-auth-session")
+ or ""
+ ).strip()
+ except Exception:
+ auth_cookie = str(self.session.cookies.get("oai-client-auth-session") or "").strip()
if not auth_cookie:
self._log("未能获取到授权 Cookie,尝试从 auth-info 里取 workspace", "warning")
@@ -2420,7 +2644,64 @@ def _extract_workspace_id(payload: Any) -> str:
break
auth_info_text = decoded
try:
- auth_info_json = json_module.loads(auth_info_text)
+ stripped = str(auth_info_text or "").strip()
+ if not stripped:
+ raise ValueError("empty auth-info after decode")
+ # 去掉可能的引号包裹
+ if (len(stripped) >= 2) and (stripped[0] == stripped[-1]) and (stripped[0] in ("'", '"')):
+ stripped = stripped[1:-1].strip()
+
+ if stripped and stripped[0] in "{[":
+ auth_info_json = json_module.loads(stripped)
+ else:
+ # 兼容 base64/url-safe JSON
+ auth_info_json = None
+ try:
+ import base64
+ candidates_raw = []
+ if stripped:
+ candidates_raw.append(stripped)
+ # 兼容 JWT/签名格式:取分段尝试解码
+ if "." in stripped:
+ for seg in stripped.split("."):
+ seg = seg.strip()
+ if seg:
+ candidates_raw.append(seg)
+
+ for candidate in candidates_raw:
+ if not candidate:
+ continue
+ pad = "=" * ((4 - (len(candidate) % 4)) % 4)
+ candidates = []
+ try:
+ candidates.append(base64.urlsafe_b64decode((candidate + pad).encode("ascii")))
+ except Exception:
+ pass
+ try:
+ candidates.append(base64.b64decode((candidate + pad).encode("ascii")))
+ except Exception:
+ pass
+ for decoded in candidates:
+ try:
+ text = decoded.decode("utf-8")
+ except Exception:
+ continue
+ # 可能再次 URL 编码
+ for _ in range(2):
+ decoded_text = urlparse.unquote(text)
+ if decoded_text == text:
+ break
+ text = decoded_text
+ text = text.strip()
+ if text and text[0] in "{[":
+ auth_info_json = json_module.loads(text)
+ break
+ if auth_info_json is not None:
+ break
+ except Exception:
+ auth_info_json = None
+ if auth_info_json is None:
+ raise ValueError(f"auth-info not json: {stripped}")
workspace_id = _extract_workspace_id(auth_info_json)
if workspace_id:
self._log(f"Workspace ID (auth-info): {workspace_id}")
@@ -2633,6 +2914,7 @@ def run(self) -> RegistrationResult:
result = RegistrationResult(success=False, logs=self.logs)
try:
+ token_ready = False
self._is_existing_account = False
self._token_acquisition_requires_login = False
self._otp_sent_at = None
@@ -2640,6 +2922,8 @@ def run(self) -> RegistrationResult:
self._create_account_workspace_id = None
self._create_account_account_id = None
self._create_account_refresh_token = None
+ self._create_account_callback_url = None
+ self._create_account_page_type = None
self._last_validate_otp_continue_url = None
self._last_validate_otp_workspace_id = None
@@ -2715,7 +2999,16 @@ def run(self) -> RegistrationResult:
result.error_message = "创建用户账户失败"
return result
- if effective_entry_flow in {"native", "outlook"}:
+ if self._create_account_callback_url:
+ self._log("create_account 已返回 OAuth callback,尝试跳过二次登录...")
+ token_ready = self._consume_create_account_callback(result)
+ if token_ready:
+ self._log("create_account callback 已完成会话交换,跳过重登流程")
+ else:
+ result.error_message = "create_account callback 获取 access_token 失败"
+ self._log("create_account callback 未能完成会话,终止本轮注册", "error")
+ return result
+ if (not token_ready) and effective_entry_flow in {"native", "outlook"}:
login_ready, login_error = self._restart_login_flow()
if not login_ready:
result.error_message = login_error
@@ -2725,16 +3018,27 @@ def run(self) -> RegistrationResult:
else:
self._log("注册入口链路: ABCard(新账号不重登,直接抓取会话)")
- if effective_entry_flow == "native":
- if not self._complete_token_exchange_native_backup(result):
- return result
- elif effective_entry_flow == "outlook":
- if not self._complete_token_exchange_outlook(result):
- return result
+ if not token_ready:
+ if effective_entry_flow == "native":
+ if not self._complete_token_exchange_native_backup(result):
+ return result
+ elif effective_entry_flow == "outlook":
+ if not self._complete_token_exchange_outlook(result):
+ return result
+ else:
+ use_abcard_entry = (effective_entry_flow == "abcard") and (not self._is_existing_account)
+ if not self._complete_token_exchange(result, require_login_otp=not use_abcard_entry):
+ return result
else:
- use_abcard_entry = (effective_entry_flow == "abcard") and (not self._is_existing_account)
- if not self._complete_token_exchange(result, require_login_otp=not use_abcard_entry):
- return result
+ # 已通过 create_account callback 拿到 token,补齐必要字段
+ if not result.account_id and self._create_account_account_id:
+ result.account_id = str(self._create_account_account_id or "").strip()
+ if not result.workspace_id and self._create_account_workspace_id:
+ result.workspace_id = str(self._create_account_workspace_id or "").strip()
+ if not result.refresh_token and self._create_account_refresh_token:
+ result.refresh_token = str(self._create_account_refresh_token or "").strip()
+ if not result.device_id:
+ result.device_id = str(self.device_id or "")
# 10. 完成
self._log("=" * 60)
diff --git a/src/services/luckmail_mail.py b/src/services/luckmail_mail.py
index 8c71cec0..97cfd8a0 100644
--- a/src/services/luckmail_mail.py
+++ b/src/services/luckmail_mail.py
@@ -18,6 +18,8 @@
logger = logging.getLogger(__name__)
_STATE_LOCK = threading.RLock()
+# 申诉硬编码开关(临时):False=关闭申诉提交;True=开启申诉提交。
+LUCKMAIL_APPEAL_ENABLED = False
def _load_luckmail_client_class():
@@ -321,15 +323,61 @@ def _mark_registered_email(self, email: str, extra: Optional[Dict[str, Any]] = N
self._save_email_index(self._registered_file, registered)
self._save_email_index(self._failed_file, failed)
- def _mark_failed_email(self, email: str, reason: str = "", extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ def _should_force_failed_record(self, reason: str) -> bool:
+ text = str(reason or "").strip().lower()
+ if not text:
+ return False
+ keywords = (
+ "该邮箱已存在 openai",
+ "邮箱已存在 openai",
+ "user_already_exists",
+ "already exists",
+ "failed to register username",
+ "用户名注册失败",
+ "创建用户账户失败",
+ )
+ return any(k in text for k in keywords)
+
+ def _reconcile_failed_over_registered(
+ self,
+ registered: Dict[str, Dict[str, Any]],
+ failed: Dict[str, Dict[str, Any]],
+ ) -> bool:
+ changed = False
+ for email, failed_meta in list(failed.items()):
+ if email not in registered:
+ continue
+ failed_reason = str((failed_meta or {}).get("reason") or "")
+ if self._should_force_failed_record(failed_reason):
+ registered.pop(email, None)
+ changed = True
+ return changed
+
+ def _mark_failed_email(
+ self,
+ email: str,
+ reason: str = "",
+ extra: Optional[Dict[str, Any]] = None,
+ prefer_failed: bool = False,
+ ) -> Dict[str, Any]:
email_norm = self._normalize_email(email)
if not email_norm:
return {}
+
registered = self._load_email_index(self._registered_file)
+ registered_record: Dict[str, Any] = {}
if email_norm in registered:
- return registered.get(email_norm) or {}
+ if not prefer_failed:
+ return registered.get(email_norm) or {}
+ registered_record = dict(registered.get(email_norm) or {})
+ registered.pop(email_norm, None)
+ self._save_email_index(self._registered_file, registered)
+
failed = self._load_email_index(self._failed_file)
record = failed.get(email_norm, {"email": email_norm, "fail_count": 0})
+ for k, v in registered_record.items():
+ if k not in record and v not in (None, ""):
+ record[k] = v
record["fail_count"] = int(record.get("fail_count") or 0) + 1
record["updated_at"] = self._now_iso()
if reason:
@@ -353,11 +401,17 @@ def mark_registration_outcome(
if success:
self._mark_registered_email(email, extra=context)
else:
+ prefer_failed = self._should_force_failed_record(reason)
context_copy = dict(context or {})
password = str(context_copy.get("generated_password") or context_copy.get("password") or "").strip()
if password:
context_copy["password"] = password
- record = self._mark_failed_email(email, reason=reason, extra=context_copy)
+ record = self._mark_failed_email(
+ email,
+ reason=reason,
+ extra=context_copy,
+ prefer_failed=prefer_failed,
+ )
self._try_submit_appeal(email=email, reason=reason, context=context_copy, failed_record=record)
def _resolve_order_id_by_order_no(self, order_no: str, max_pages: int = 3, page_size: int = 50) -> Optional[int]:
@@ -453,6 +507,9 @@ def _try_submit_appeal(
context: Dict[str, Any],
failed_record: Optional[Dict[str, Any]] = None,
) -> None:
+ if not LUCKMAIL_APPEAL_ENABLED:
+ return
+
reason_text = str(reason or "").strip()
if not reason_text:
return
@@ -463,6 +520,8 @@ def _try_submit_appeal(
or "限流" in reason_text
or "验证码" in reason_text
or "otp" in reason_lower
+ or "failed to register username" in reason_lower
+ or "用户名注册失败" in reason_text
or "创建用户账户失败" in reason_text
or "该邮箱已存在 openai" in reason_lower
or "user_already_exists" in reason_lower
@@ -606,6 +665,8 @@ def _pick_reusable_purchase_inbox(
) -> Optional[Dict[str, Any]]:
registered = self._load_email_index(self._registered_file)
failed = self._load_email_index(self._failed_file)
+ if self._reconcile_failed_over_registered(registered, failed):
+ self._save_email_index(self._registered_file, registered)
candidates: List[Dict[str, Any]] = []
for item in self._iter_purchase_items(
diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py
index 152461e8..0ca20da9 100644
--- a/src/web/routes/accounts.py
+++ b/src/web/routes/accounts.py
@@ -19,7 +19,7 @@
from ...config.constants import AccountStatus
from ...config.settings import get_settings
-from ...core.openai.overview import fetch_codex_overview
+from ...core.openai.overview import fetch_codex_overview, AccountDeactivatedError
from ...core.openai.token_refresh import refresh_account_token as do_refresh
from ...core.openai.token_refresh import validate_account_token as do_validate
from ...core.upload.cpa_upload import generate_token_json, batch_upload_to_cpa, upload_to_cpa
@@ -603,6 +603,17 @@ def _get_account_overview_data(
account.extra_data = merged_extra
updated = True
return overview, updated
+ except AccountDeactivatedError as exc:
+ logger.warning("账号被停用: email=%s err=%s", account.email, exc)
+ account.status = AccountStatus.BANNED.value
+ merged_extra = dict(extra_data)
+ merged_extra[OVERVIEW_EXTRA_DATA_KEY] = _fallback_overview(
+ account, error_message="account_deactivated", stale=True
+ )
+ merged_extra["account_deactivated_at"] = datetime.now(timezone.utc).isoformat()
+ account.extra_data = merged_extra
+ updated = True
+ return merged_extra[OVERVIEW_EXTRA_DATA_KEY], updated
except Exception as exc:
logger.warning(f"刷新账号[{account.email}]总览失败: {exc}")
if cached:
From 2eb200e212c39164e776192c5a5b0c8a15594bc4 Mon Sep 17 00:00:00 2001
From: cong <3135055939@qq.com>
Date: Tue, 31 Mar 2026 13:11:59 +0800
Subject: [PATCH 4/8] Rewrite registration flow to mirror any-auto-register
---
src/core/anyauto/__init__.py | 0
src/core/anyauto/chatgpt_client.py | 872 +++++++++++++++++
src/core/anyauto/oauth_client.py | 1422 ++++++++++++++++++++++++++++
src/core/anyauto/register_flow.py | 313 ++++++
src/core/anyauto/sentinel_token.py | 206 ++++
src/core/anyauto/utils.py | 362 +++++++
src/core/register.py | 195 +---
7 files changed, 3214 insertions(+), 156 deletions(-)
create mode 100644 src/core/anyauto/__init__.py
create mode 100644 src/core/anyauto/chatgpt_client.py
create mode 100644 src/core/anyauto/oauth_client.py
create mode 100644 src/core/anyauto/register_flow.py
create mode 100644 src/core/anyauto/sentinel_token.py
create mode 100644 src/core/anyauto/utils.py
diff --git a/src/core/anyauto/__init__.py b/src/core/anyauto/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/core/anyauto/chatgpt_client.py b/src/core/anyauto/chatgpt_client.py
new file mode 100644
index 00000000..0f4cb825
--- /dev/null
+++ b/src/core/anyauto/chatgpt_client.py
@@ -0,0 +1,872 @@
+"""
+ChatGPT 注册客户端模块
+使用 curl_cffi 模拟浏览器行为
+"""
+
+import random
+import uuid
+import time
+from urllib.parse import urlparse
+
+try:
+ from curl_cffi import requests as curl_requests
+except ImportError:
+ print("❌ 需要安装 curl_cffi: pip install curl_cffi")
+ import sys
+ sys.exit(1)
+
+from .sentinel_token import build_sentinel_token
+from .utils import (
+ FlowState,
+ build_browser_headers,
+ decode_jwt_payload,
+ describe_flow_state,
+ extract_flow_state,
+ generate_datadog_trace,
+ normalize_flow_url,
+ random_delay,
+ seed_oai_device_cookie,
+)
+
+
+# Chrome 指纹配置
+_CHROME_PROFILES = [
+ {
+ "major": 131, "impersonate": "chrome131",
+ "build": 6778, "patch_range": (69, 205),
+ "sec_ch_ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
+ },
+ {
+ "major": 133, "impersonate": "chrome133a",
+ "build": 6943, "patch_range": (33, 153),
+ "sec_ch_ua": '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"',
+ },
+ {
+ "major": 136, "impersonate": "chrome136",
+ "build": 7103, "patch_range": (48, 175),
+ "sec_ch_ua": '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"',
+ },
+]
+
+
+def _random_chrome_version():
+ """随机选择一个 Chrome 版本"""
+ profile = random.choice(_CHROME_PROFILES)
+ major = profile["major"]
+ build = profile["build"]
+ patch = random.randint(*profile["patch_range"])
+ full_ver = f"{major}.0.{build}.{patch}"
+ ua = f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{full_ver} Safari/537.36"
+ return profile["impersonate"], major, full_ver, ua, profile["sec_ch_ua"]
+
+
+class ChatGPTClient:
+ """ChatGPT 注册客户端"""
+
+ BASE = "https://chatgpt.com"
+ AUTH = "https://auth.openai.com"
+
+ def __init__(self, proxy=None, verbose=True, browser_mode="protocol"):
+ """
+ 初始化 ChatGPT 客户端
+
+ Args:
+ proxy: 代理地址
+ verbose: 是否输出详细日志
+ browser_mode: protocol | headless | headed
+ """
+ self.proxy = proxy
+ self.verbose = verbose
+ self.browser_mode = browser_mode or "protocol"
+ self.device_id = str(uuid.uuid4())
+ self.accept_language = random.choice([
+ "en-US,en;q=0.9",
+ "en-US,en;q=0.9,zh-CN;q=0.8",
+ "en,en-US;q=0.9",
+ "en-US,en;q=0.8",
+ ])
+
+ # 随机 Chrome 版本
+ self.impersonate, self.chrome_major, self.chrome_full, self.ua, self.sec_ch_ua = _random_chrome_version()
+
+ # 创建 session
+ self.session = curl_requests.Session(impersonate=self.impersonate)
+
+ if self.proxy:
+ self.session.proxies = {"http": self.proxy, "https": self.proxy}
+
+ # 设置基础 headers
+ self.session.headers.update({
+ "User-Agent": self.ua,
+ "Accept-Language": self.accept_language,
+ "sec-ch-ua": self.sec_ch_ua,
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": '"Windows"',
+ "sec-ch-ua-arch": '"x86"',
+ "sec-ch-ua-bitness": '"64"',
+ "sec-ch-ua-full-version": f'"{self.chrome_full}"',
+ "sec-ch-ua-platform-version": f'"{random.randint(10, 15)}.0.0"',
+ })
+
+ # 设置 oai-did cookie
+ seed_oai_device_cookie(self.session, self.device_id)
+ self.last_registration_state = FlowState()
+
+ def _log(self, msg):
+ """输出日志"""
+ if self.verbose:
+ print(f" {msg}")
+
+ def _browser_pause(self, low=0.15, high=0.45):
+ """在 headed 模式下加入轻微停顿,模拟有头浏览器节奏。"""
+ if self.browser_mode == "headed":
+ random_delay(low, high)
+
+ def _headers(
+ self,
+ url,
+ *,
+ accept,
+ referer=None,
+ origin=None,
+ content_type=None,
+ navigation=False,
+ fetch_mode=None,
+ fetch_dest=None,
+ fetch_site=None,
+ extra_headers=None,
+ ):
+ return build_browser_headers(
+ url=url,
+ user_agent=self.ua,
+ sec_ch_ua=self.sec_ch_ua,
+ chrome_full_version=self.chrome_full,
+ accept=accept,
+ accept_language=self.accept_language,
+ referer=referer,
+ origin=origin,
+ content_type=content_type,
+ navigation=navigation,
+ fetch_mode=fetch_mode,
+ fetch_dest=fetch_dest,
+ fetch_site=fetch_site,
+ headed=self.browser_mode == "headed",
+ extra_headers=extra_headers,
+ )
+
+ def _reset_session(self):
+ """重置浏览器指纹与会话,用于绕过偶发的 Cloudflare/SPA 中间页。"""
+ self.device_id = str(uuid.uuid4())
+ self.impersonate, self.chrome_major, self.chrome_full, self.ua, self.sec_ch_ua = _random_chrome_version()
+ self.accept_language = random.choice([
+ "en-US,en;q=0.9",
+ "en-US,en;q=0.9,zh-CN;q=0.8",
+ "en,en-US;q=0.9",
+ "en-US,en;q=0.8",
+ ])
+
+ self.session = curl_requests.Session(impersonate=self.impersonate)
+ if self.proxy:
+ self.session.proxies = {"http": self.proxy, "https": self.proxy}
+
+ self.session.headers.update({
+ "User-Agent": self.ua,
+ "Accept-Language": self.accept_language,
+ "sec-ch-ua": self.sec_ch_ua,
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": '"Windows"',
+ "sec-ch-ua-arch": '"x86"',
+ "sec-ch-ua-bitness": '"64"',
+ "sec-ch-ua-full-version": f'"{self.chrome_full}"',
+ "sec-ch-ua-platform-version": f'"{random.randint(10, 15)}.0.0"',
+ })
+ seed_oai_device_cookie(self.session, self.device_id)
+
+ def _state_from_url(self, url, method="GET"):
+ state = extract_flow_state(
+ current_url=normalize_flow_url(url, auth_base=self.AUTH),
+ auth_base=self.AUTH,
+ default_method=method,
+ )
+ if method:
+ state.method = str(method).upper()
+ return state
+
+ def _state_from_payload(self, data, current_url=""):
+ return extract_flow_state(
+ data=data,
+ current_url=current_url,
+ auth_base=self.AUTH,
+ )
+
+ def _state_signature(self, state: FlowState):
+ return (
+ state.page_type or "",
+ state.method or "",
+ state.continue_url or "",
+ state.current_url or "",
+ )
+
+ def _is_registration_complete_state(self, state: FlowState):
+ current_url = (state.current_url or "").lower()
+ continue_url = (state.continue_url or "").lower()
+ page_type = state.page_type or ""
+ return (
+ page_type in {"callback", "chatgpt_home", "oauth_callback"}
+ or ("chatgpt.com" in current_url and "redirect_uri" not in current_url)
+ or ("chatgpt.com" in continue_url and "redirect_uri" not in continue_url and page_type != "external_url")
+ )
+
+ def _state_is_password_registration(self, state: FlowState):
+ return state.page_type in {"create_account_password", "password"}
+
+ def _state_is_email_otp(self, state: FlowState):
+ target = f"{state.continue_url} {state.current_url}".lower()
+ return state.page_type == "email_otp_verification" or "email-verification" in target or "email-otp" in target
+
+ def _state_is_about_you(self, state: FlowState):
+ target = f"{state.continue_url} {state.current_url}".lower()
+ return state.page_type == "about_you" or "about-you" in target
+
+ def _state_requires_navigation(self, state: FlowState):
+ if (state.method or "GET").upper() != "GET":
+ return False
+ if state.page_type == "external_url" and state.continue_url:
+ return True
+ if state.continue_url and state.continue_url != state.current_url:
+ return True
+ return False
+
+ def _follow_flow_state(self, state: FlowState, referer=None):
+ """跟随服务端返回的 continue_url,推进注册状态机。"""
+ target_url = state.continue_url or state.current_url
+ if not target_url:
+ return False, "缺少可跟随的 continue_url"
+
+ try:
+ self._browser_pause()
+ r = self.session.get(
+ target_url,
+ headers=self._headers(
+ target_url,
+ accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ referer=referer,
+ navigation=True,
+ ),
+ allow_redirects=True,
+ timeout=30,
+ )
+ final_url = str(r.url)
+ self._log(f"follow -> {r.status_code} {final_url}")
+
+ content_type = (r.headers.get("content-type", "") or "").lower()
+ if "application/json" in content_type:
+ try:
+ next_state = self._state_from_payload(r.json(), current_url=final_url)
+ except Exception:
+ next_state = self._state_from_url(final_url)
+ else:
+ next_state = self._state_from_url(final_url)
+
+ self._log(f"follow state -> {describe_flow_state(next_state)}")
+ return True, next_state
+ except Exception as e:
+ self._log(f"跟随 continue_url 失败: {e}")
+ return False, str(e)
+
+ def _get_cookie_value(self, name, domain_hint=None):
+ """读取当前会话中的 Cookie。"""
+ for cookie in self.session.cookies.jar:
+ if cookie.name != name:
+ continue
+ if domain_hint and domain_hint not in (cookie.domain or ""):
+ continue
+ return cookie.value
+ return ""
+
+ def get_next_auth_session_token(self):
+ """获取 ChatGPT next-auth 会话 Cookie。"""
+ return self._get_cookie_value("__Secure-next-auth.session-token", "chatgpt.com")
+
+ def fetch_chatgpt_session(self):
+ """请求 ChatGPT Session 接口并返回原始会话数据。"""
+ url = f"{self.BASE}/api/auth/session"
+ self._browser_pause()
+ response = self.session.get(
+ url,
+ headers=self._headers(
+ url,
+ accept="application/json",
+ referer=f"{self.BASE}/",
+ fetch_site="same-origin",
+ ),
+ timeout=30,
+ )
+ if response.status_code != 200:
+ return False, f"/api/auth/session -> HTTP {response.status_code}"
+
+ try:
+ data = response.json()
+ except Exception as exc:
+ return False, f"/api/auth/session 返回非 JSON: {exc}"
+
+ access_token = str(data.get("accessToken") or "").strip()
+ if not access_token:
+ return False, "/api/auth/session 未返回 accessToken"
+ return True, data
+
+ def reuse_session_and_get_tokens(self):
+ """
+ 复用注册阶段已建立的 ChatGPT 会话,直接读取 Session / AccessToken。
+
+ Returns:
+ tuple[bool, dict|str]: 成功时返回标准化 token/session 数据;失败时返回错误信息。
+ """
+ state = self.last_registration_state or FlowState()
+ self._log("步骤 1/4: 跟随注册回调 external_url ...")
+ if state.page_type == "external_url" or self._state_requires_navigation(state):
+ ok, followed = self._follow_flow_state(
+ state,
+ referer=state.current_url or f"{self.AUTH}/about-you",
+ )
+ if not ok:
+ return False, f"注册回调落地失败: {followed}"
+ self.last_registration_state = followed
+ else:
+ self._log("注册回调已落地,跳过额外跟随")
+
+ self._log("步骤 2/4: 检查 __Secure-next-auth.session-token ...")
+ session_cookie = self.get_next_auth_session_token()
+ if not session_cookie:
+ return False, "缺少 __Secure-next-auth.session-token,注册回调可能未落地"
+
+ self._log("步骤 3/4: 请求 ChatGPT /api/auth/session ...")
+ ok, session_or_error = self.fetch_chatgpt_session()
+ if not ok:
+ return False, session_or_error
+
+ session_data = session_or_error
+ access_token = str(session_data.get("accessToken") or "").strip()
+ session_token = str(session_data.get("sessionToken") or session_cookie or "").strip()
+ user = session_data.get("user") or {}
+ account = session_data.get("account") or {}
+ jwt_payload = decode_jwt_payload(access_token)
+ auth_payload = jwt_payload.get("https://api.openai.com/auth") or {}
+
+ account_id = (
+ str(account.get("id") or "").strip()
+ or str(auth_payload.get("chatgpt_account_id") or "").strip()
+ )
+ user_id = (
+ str(user.get("id") or "").strip()
+ or str(auth_payload.get("chatgpt_user_id") or "").strip()
+ or str(auth_payload.get("user_id") or "").strip()
+ )
+
+ normalized = {
+ "access_token": access_token,
+ "session_token": session_token,
+ "account_id": account_id,
+ "user_id": user_id,
+ "workspace_id": account_id,
+ "expires": session_data.get("expires"),
+ "user": user,
+ "account": account,
+ "auth_provider": session_data.get("authProvider"),
+ "raw_session": session_data,
+ }
+
+ self._log("步骤 4/4: 已从复用会话中提取 accessToken")
+ if account_id:
+ self._log(f"Session Account ID: {account_id}")
+ if user_id:
+ self._log(f"Session User ID: {user_id}")
+ return True, normalized
+
+ def visit_homepage(self):
+ """访问首页,建立 session"""
+ self._log("访问 ChatGPT 首页...")
+ url = f"{self.BASE}/"
+ try:
+ self._browser_pause()
+ r = self.session.get(
+ url,
+ headers=self._headers(
+ url,
+ accept="text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
+ navigation=True,
+ ),
+ allow_redirects=True,
+ timeout=30,
+ )
+ return r.status_code == 200
+ except Exception as e:
+ self._log(f"访问首页失败: {e}")
+ return False
+
+ def get_csrf_token(self):
+ """获取 CSRF token"""
+ self._log("获取 CSRF token...")
+ url = f"{self.BASE}/api/auth/csrf"
+ try:
+ r = self.session.get(
+ url,
+ headers=self._headers(
+ url,
+ accept="application/json",
+ referer=f"{self.BASE}/",
+ fetch_site="same-origin",
+ ),
+ timeout=30,
+ )
+
+ if r.status_code == 200:
+ data = r.json()
+ token = data.get("csrfToken", "")
+ if token:
+ self._log(f"CSRF token: {token[:20]}...")
+ return token
+ except Exception as e:
+ self._log(f"获取 CSRF token 失败: {e}")
+
+ return None
+
+ def signin(self, email, csrf_token):
+ """
+ 提交邮箱,获取 authorize URL
+
+ Returns:
+ str: authorize URL
+ """
+ self._log(f"提交邮箱: {email}")
+ url = f"{self.BASE}/api/auth/signin/openai"
+
+ params = {
+ "prompt": "login",
+ "ext-oai-did": self.device_id,
+ "auth_session_logging_id": str(uuid.uuid4()),
+ "screen_hint": "login_or_signup",
+ "login_hint": email,
+ }
+
+ form_data = {
+ "callbackUrl": f"{self.BASE}/",
+ "csrfToken": csrf_token,
+ "json": "true",
+ }
+
+ try:
+ self._browser_pause()
+ r = self.session.post(
+ url,
+ params=params,
+ data=form_data,
+ headers=self._headers(
+ url,
+ accept="application/json",
+ referer=f"{self.BASE}/",
+ origin=self.BASE,
+ content_type="application/x-www-form-urlencoded",
+ fetch_site="same-origin",
+ ),
+ timeout=30
+ )
+
+ if r.status_code == 200:
+ data = r.json()
+ authorize_url = data.get("url", "")
+ if authorize_url:
+ self._log(f"获取到 authorize URL")
+ return authorize_url
+ except Exception as e:
+ self._log(f"提交邮箱失败: {e}")
+
+ return None
+
+ def authorize(self, url, max_retries=3):
+ """
+ 访问 authorize URL,跟随重定向(带重试机制)
+ 这是关键步骤,建立 auth.openai.com 的 session
+
+ Returns:
+ str: 最终重定向的 URL
+ """
+ for attempt in range(max_retries):
+ try:
+ if attempt > 0:
+ self._log(f"访问 authorize URL... (尝试 {attempt + 1}/{max_retries})")
+ time.sleep(1) # 重试前等待
+ else:
+ self._log("访问 authorize URL...")
+
+ self._browser_pause()
+ r = self.session.get(
+ url,
+ headers=self._headers(
+ url,
+ accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ referer=f"{self.BASE}/",
+ navigation=True,
+ ),
+ allow_redirects=True,
+ timeout=30,
+ )
+
+ final_url = str(r.url)
+ self._log(f"重定向到: {final_url}")
+ return final_url
+
+ except Exception as e:
+ error_msg = str(e)
+ is_tls_error = "TLS" in error_msg or "SSL" in error_msg or "curl: (35)" in error_msg
+
+ if is_tls_error and attempt < max_retries - 1:
+ self._log(f"Authorize TLS 错误 (尝试 {attempt + 1}/{max_retries}): {error_msg[:100]}")
+ continue
+ else:
+ self._log(f"Authorize 失败: {e}")
+ return ""
+
+ return ""
+
+ def callback(self, callback_url=None, referer=None):
+ """完成注册回调"""
+ self._log("执行回调...")
+ url = callback_url or f"{self.AUTH}/api/accounts/authorize/callback"
+ ok, _ = self._follow_flow_state(
+ self._state_from_url(url),
+ referer=referer or f"{self.AUTH}/about-you",
+ )
+ return ok
+
+ def register_user(self, email, password):
+ """
+ 注册用户(邮箱 + 密码)
+
+ Returns:
+ tuple: (success, message)
+ """
+ self._log(f"注册用户: {email}")
+ url = f"{self.AUTH}/api/accounts/user/register"
+
+ headers = self._headers(
+ url,
+ accept="application/json",
+ referer=f"{self.AUTH}/create-account/password",
+ origin=self.AUTH,
+ content_type="application/json",
+ fetch_site="same-origin",
+ )
+ headers.update(generate_datadog_trace())
+
+ payload = {
+ "username": email,
+ "password": password,
+ }
+
+ try:
+ self._browser_pause()
+ r = self.session.post(url, json=payload, headers=headers, timeout=30)
+
+ if r.status_code == 200:
+ data = r.json()
+ self._log("注册成功")
+ return True, "注册成功"
+ else:
+ try:
+ error_data = r.json()
+ error_msg = error_data.get("error", {}).get("message", r.text[:200])
+ except:
+ error_msg = r.text[:200]
+ self._log(f"注册失败: {r.status_code} - {error_msg}")
+ return False, f"HTTP {r.status_code}: {error_msg}"
+
+ except Exception as e:
+ self._log(f"注册异常: {e}")
+ return False, str(e)
+
+ def send_email_otp(self):
+ """触发发送邮箱验证码"""
+ self._log("触发发送验证码...")
+ url = f"{self.AUTH}/api/accounts/email-otp/send"
+
+ try:
+ self._browser_pause()
+ r = self.session.get(
+ url,
+ headers=self._headers(
+ url,
+ accept="application/json, text/plain, */*",
+ referer=f"{self.AUTH}/create-account/password",
+ fetch_site="same-origin",
+ ),
+ allow_redirects=True,
+ timeout=30,
+ )
+ return r.status_code == 200
+ except Exception as e:
+ self._log(f"发送验证码失败: {e}")
+ return False
+
+ def verify_email_otp(self, otp_code, return_state=False):
+ """
+ 验证邮箱 OTP 码
+
+ Args:
+ otp_code: 6位验证码
+
+ Returns:
+ tuple: (success, message)
+ """
+ self._log(f"验证 OTP 码: {otp_code}")
+ url = f"{self.AUTH}/api/accounts/email-otp/validate"
+
+ headers = self._headers(
+ url,
+ accept="application/json",
+ referer=f"{self.AUTH}/email-verification",
+ origin=self.AUTH,
+ content_type="application/json",
+ fetch_site="same-origin",
+ )
+ headers.update(generate_datadog_trace())
+
+ payload = {"code": otp_code}
+
+ try:
+ self._browser_pause()
+ r = self.session.post(url, json=payload, headers=headers, timeout=30)
+
+ if r.status_code == 200:
+ try:
+ data = r.json()
+ except Exception:
+ data = {}
+ next_state = self._state_from_payload(data, current_url=str(r.url) or f"{self.AUTH}/about-you")
+ self._log(f"验证成功 {describe_flow_state(next_state)}")
+ return (True, next_state) if return_state else (True, "验证成功")
+ else:
+ error_msg = r.text[:200]
+ self._log(f"验证失败: {r.status_code} - {error_msg}")
+ return False, f"HTTP {r.status_code}"
+
+ except Exception as e:
+ self._log(f"验证异常: {e}")
+ return False, str(e)
+
+ def create_account(self, first_name, last_name, birthdate, return_state=False):
+ """
+ 完成账号创建(提交姓名和生日)
+
+ Args:
+ first_name: 名
+ last_name: 姓
+ birthdate: 生日 (YYYY-MM-DD)
+
+ Returns:
+ tuple: (success, message)
+ """
+ name = f"{first_name} {last_name}"
+ self._log(f"完成账号创建: {name}")
+ url = f"{self.AUTH}/api/accounts/create_account"
+
+ sentinel_token = build_sentinel_token(
+ self.session,
+ self.device_id,
+ flow="authorize_continue",
+ user_agent=self.ua,
+ sec_ch_ua=self.sec_ch_ua,
+ impersonate=self.impersonate,
+ )
+ if sentinel_token:
+ self._log("create_account: 已生成 sentinel token")
+ else:
+ self._log("create_account: 未生成 sentinel token,降级继续请求")
+
+ headers = self._headers(
+ url,
+ accept="application/json",
+ referer=f"{self.AUTH}/about-you",
+ origin=self.AUTH,
+ content_type="application/json",
+ fetch_site="same-origin",
+ extra_headers={
+ "oai-device-id": self.device_id,
+ },
+ )
+ if sentinel_token:
+ headers["openai-sentinel-token"] = sentinel_token
+ headers.update(generate_datadog_trace())
+
+ payload = {
+ "name": name,
+ "birthdate": birthdate,
+ }
+
+ try:
+ self._browser_pause()
+ r = self.session.post(url, json=payload, headers=headers, timeout=30)
+
+ if r.status_code == 200:
+ try:
+ data = r.json()
+ except Exception:
+ data = {}
+ next_state = self._state_from_payload(data, current_url=str(r.url) or self.BASE)
+ self._log(f"账号创建成功 {describe_flow_state(next_state)}")
+ return (True, next_state) if return_state else (True, "账号创建成功")
+ else:
+ error_msg = r.text[:200]
+ self._log(f"创建失败: {r.status_code} - {error_msg}")
+ return False, f"HTTP {r.status_code}"
+
+ except Exception as e:
+ self._log(f"创建异常: {e}")
+ return False, str(e)
+
+ def register_complete_flow(self, email, password, first_name, last_name, birthdate, skymail_client):
+ """
+ 完整的注册流程(基于原版 run_register 方法)
+
+ Args:
+ email: 邮箱
+ password: 密码
+ first_name: 名
+ last_name: 姓
+ birthdate: 生日
+ skymail_client: Skymail 客户端(用于获取验证码)
+
+ Returns:
+ tuple: (success, message)
+ """
+ from urllib.parse import urlparse
+
+ max_auth_attempts = 3
+ final_url = ""
+ final_path = ""
+
+ for auth_attempt in range(max_auth_attempts):
+ if auth_attempt > 0:
+ self._log(f"预授权阶段重试 {auth_attempt + 1}/{max_auth_attempts}...")
+ self._reset_session()
+
+ # 1. 访问首页
+ if not self.visit_homepage():
+ if auth_attempt < max_auth_attempts - 1:
+ continue
+ return False, "访问首页失败"
+
+ # 2. 获取 CSRF token
+ csrf_token = self.get_csrf_token()
+ if not csrf_token:
+ if auth_attempt < max_auth_attempts - 1:
+ continue
+ return False, "获取 CSRF token 失败"
+
+ # 3. 提交邮箱,获取 authorize URL
+ auth_url = self.signin(email, csrf_token)
+ if not auth_url:
+ if auth_attempt < max_auth_attempts - 1:
+ continue
+ return False, "提交邮箱失败"
+
+ # 4. 访问 authorize URL(关键步骤!)
+ final_url = self.authorize(auth_url)
+ if not final_url:
+ if auth_attempt < max_auth_attempts - 1:
+ continue
+ return False, "Authorize 失败"
+
+ final_path = urlparse(final_url).path
+ self._log(f"Authorize → {final_path}")
+
+ # /api/accounts/authorize 实际上常对应 Cloudflare 403 中间页,不要继续走 authorize_continue。
+ if "api/accounts/authorize" in final_path or final_path == "/error":
+ self._log(f"检测到 Cloudflare/SPA 中间页,准备重试预授权: {final_url[:160]}...")
+ if auth_attempt < max_auth_attempts - 1:
+ continue
+ return False, f"预授权被拦截: {final_path}"
+
+ break
+
+ state = self._state_from_url(final_url)
+ self._log(f"注册状态起点: {describe_flow_state(state)}")
+
+ register_submitted = False
+ otp_verified = False
+ account_created = False
+ seen_states = {}
+
+ for _ in range(12):
+ signature = self._state_signature(state)
+ seen_states[signature] = seen_states.get(signature, 0) + 1
+ if seen_states[signature] > 2:
+ return False, f"注册状态卡住: {describe_flow_state(state)}"
+
+ if self._is_registration_complete_state(state):
+ self.last_registration_state = state
+ self._log("✅ 注册流程完成")
+ return True, "注册成功"
+
+ if self._state_is_password_registration(state):
+ self._log("全新注册流程")
+ if register_submitted:
+ return False, "注册密码阶段重复进入"
+ success, msg = self.register_user(email, password)
+ if not success:
+ return False, f"注册失败: {msg}"
+ register_submitted = True
+ if not self.send_email_otp():
+ self._log("发送验证码接口返回失败,继续等待邮箱中的验证码...")
+ state = self._state_from_url(f"{self.AUTH}/email-verification")
+ continue
+
+ if self._state_is_email_otp(state):
+ self._log("等待邮箱验证码...")
+ otp_code = skymail_client.wait_for_verification_code(email, timeout=90)
+ if not otp_code:
+ return False, "未收到验证码"
+
+ success, next_state = self.verify_email_otp(otp_code, return_state=True)
+ if not success:
+ return False, f"验证码失败: {next_state}"
+ otp_verified = True
+ state = next_state
+ self.last_registration_state = state
+ continue
+
+ if self._state_is_about_you(state):
+ if account_created:
+ return False, "填写信息阶段重复进入"
+ success, next_state = self.create_account(
+ first_name,
+ last_name,
+ birthdate,
+ return_state=True,
+ )
+ if not success:
+ return False, f"创建账号失败: {next_state}"
+ account_created = True
+ state = next_state
+ self.last_registration_state = state
+ continue
+
+ if self._state_requires_navigation(state):
+ success, next_state = self._follow_flow_state(
+ state,
+ referer=state.current_url or f"{self.AUTH}/about-you",
+ )
+ if not success:
+ return False, f"跳转失败: {next_state}"
+ state = next_state
+ self.last_registration_state = state
+ continue
+
+ if (not register_submitted) and (not otp_verified) and (not account_created):
+ self._log(f"未知起始状态,回退为全新注册流程: {describe_flow_state(state)}")
+ state = self._state_from_url(f"{self.AUTH}/create-account/password")
+ continue
+
+ return False, f"未支持的注册状态: {describe_flow_state(state)}"
+
+ return False, "注册状态机超出最大步数"
diff --git a/src/core/anyauto/oauth_client.py b/src/core/anyauto/oauth_client.py
new file mode 100644
index 00000000..494a5320
--- /dev/null
+++ b/src/core/anyauto/oauth_client.py
@@ -0,0 +1,1422 @@
+"""
+OAuth 客户端模块 - 处理 Codex OAuth 登录流程
+"""
+
+import time
+import secrets
+from urllib.parse import urlparse, parse_qs
+
+try:
+ from curl_cffi import requests as curl_requests
+except ImportError:
+ import requests as curl_requests
+
+from .utils import (
+ FlowState,
+ build_browser_headers,
+ describe_flow_state,
+ extract_flow_state,
+ generate_datadog_trace,
+ generate_pkce,
+ normalize_flow_url,
+ random_delay,
+ seed_oai_device_cookie,
+)
+from .sentinel_token import build_sentinel_token
+
+
+class OAuthClient:
+ """OAuth 客户端 - 用于获取 Access Token 和 Refresh Token"""
+
+ def __init__(self, config, proxy=None, verbose=True, browser_mode="protocol"):
+ """
+ 初始化 OAuth 客户端
+
+ Args:
+ config: 配置字典
+ proxy: 代理地址
+ verbose: 是否输出详细日志
+ browser_mode: protocol | headless | headed
+ """
+ self.config = dict(config or {})
+ self.oauth_issuer = self.config.get("oauth_issuer", "https://auth.openai.com")
+ self.oauth_client_id = self.config.get("oauth_client_id", "app_EMoamEEZ73f0CkXaXp7hrann")
+ self.oauth_redirect_uri = self.config.get("oauth_redirect_uri", "http://localhost:1455/auth/callback")
+ self.proxy = proxy
+ self.verbose = verbose
+ self.browser_mode = browser_mode or "protocol"
+ self.last_error = ""
+
+ # 创建 session
+ self.session = curl_requests.Session()
+ if self.proxy:
+ self.session.proxies = {"http": self.proxy, "https": self.proxy}
+
+ def _log(self, msg):
+ """输出日志"""
+ if self.verbose:
+ print(f" [OAuth] {msg}")
+
+ def _set_error(self, message):
+ self.last_error = str(message or "").strip()
+ if self.last_error:
+ self._log(self.last_error)
+
+ def _browser_pause(self, low=0.15, high=0.4):
+ """在 headed 模式下注入轻微延迟,模拟真实浏览器操作节奏。"""
+ if self.browser_mode == "headed":
+ random_delay(low, high)
+
+ @staticmethod
+ def _iter_text_fragments(value):
+ if isinstance(value, str):
+ text = value.strip()
+ if text:
+ yield text
+ return
+ if isinstance(value, dict):
+ for item in value.values():
+ yield from OAuthClient._iter_text_fragments(item)
+ return
+ if isinstance(value, (list, tuple, set)):
+ for item in value:
+ yield from OAuthClient._iter_text_fragments(item)
+
+ @classmethod
+ def _should_blacklist_phone_failure(cls, detail="", state: FlowState | None = None):
+ fragments = [str(detail or "").strip()]
+ if state is not None:
+ fragments.extend(
+ cls._iter_text_fragments(
+ {
+ "page_type": state.page_type,
+ "continue_url": state.continue_url,
+ "current_url": state.current_url,
+ "payload": state.payload,
+ "raw": state.raw,
+ }
+ )
+ )
+
+ combined = " | ".join(fragment for fragment in fragments if fragment).lower()
+ if not combined:
+ return False
+
+ non_blacklist_markers = (
+ "whatsapp",
+ "未收到短信验证码",
+ "手机号验证码错误",
+ "phone-otp/resend",
+ "phone-otp/validate 异常",
+ "phone-otp/validate 响应不是 json",
+ "phone-otp/validate 失败",
+ "timeout",
+ "timed out",
+ "network",
+ "connection",
+ "proxy",
+ "ssl",
+ "tls",
+ "captcha",
+ "too many phone",
+ "too many phone numbers",
+ "too many verification requests",
+ "验证请求过多",
+ "接受短信次数过多",
+ "session limit",
+ "rate limit",
+ )
+ if any(marker in combined for marker in non_blacklist_markers):
+ return False
+
+ blacklist_markers = (
+ "phone number is invalid",
+ "invalid phone number",
+ "invalid phone",
+ "phone number invalid",
+ "sms verification failed",
+ "send sms verification failed",
+ "unable to send sms",
+ "not a valid mobile number",
+ "unsupported phone number",
+ "phone number not supported",
+ "carrier not supported",
+ "电话号码无效",
+ "手机号无效",
+ "发送短信验证失败",
+ "号码无效",
+ "号码不支持",
+ "手机号不支持",
+ )
+ return any(marker in combined for marker in blacklist_markers)
+
+ def _blacklist_phone_if_needed(self, phone_service, entry, detail="", state: FlowState | None = None):
+ if not entry or not self._should_blacklist_phone_failure(detail, state):
+ return False
+ try:
+ phone_service.mark_blacklisted(entry.phone)
+ self._log(f"已将手机号加入黑名单: {entry.phone}")
+ return True
+ except Exception as e:
+ self._log(f"写入手机号黑名单失败: {e}")
+ return False
+
+ def _headers(
+ self,
+ url,
+ *,
+ user_agent=None,
+ sec_ch_ua=None,
+ accept,
+ referer=None,
+ origin=None,
+ content_type=None,
+ navigation=False,
+ fetch_mode=None,
+ fetch_dest=None,
+ fetch_site=None,
+ extra_headers=None,
+ ):
+ accept_language = None
+ try:
+ accept_language = self.session.headers.get("Accept-Language")
+ except Exception:
+ accept_language = None
+
+ return build_browser_headers(
+ url=url,
+ user_agent=user_agent or "Mozilla/5.0",
+ sec_ch_ua=sec_ch_ua,
+ accept=accept,
+ accept_language=accept_language or "en-US,en;q=0.9",
+ referer=referer,
+ origin=origin,
+ content_type=content_type,
+ navigation=navigation,
+ fetch_mode=fetch_mode,
+ fetch_dest=fetch_dest,
+ fetch_site=fetch_site,
+ headed=self.browser_mode == "headed",
+ extra_headers=extra_headers,
+ )
+
+ def _state_from_url(self, url, method="GET"):
+ state = extract_flow_state(
+ current_url=normalize_flow_url(url, auth_base=self.oauth_issuer),
+ auth_base=self.oauth_issuer,
+ default_method=method,
+ )
+ if method:
+ state.method = str(method).upper()
+ return state
+
+ def _state_from_payload(self, data, current_url=""):
+ return extract_flow_state(
+ data=data,
+ current_url=current_url,
+ auth_base=self.oauth_issuer,
+ )
+
+ def _state_signature(self, state: FlowState):
+ return (
+ state.page_type or "",
+ state.method or "",
+ state.continue_url or "",
+ state.current_url or "",
+ )
+
+ def _extract_code_from_state(self, state: FlowState):
+ for candidate in (
+ state.continue_url,
+ state.current_url,
+ (state.payload or {}).get("url", ""),
+ ):
+ code = self._extract_code_from_url(candidate)
+ if code:
+ return code
+ return None
+
+ def _state_is_login_password(self, state: FlowState):
+ return state.page_type == "login_password"
+
+ def _state_is_email_otp(self, state: FlowState):
+ target = f"{state.continue_url} {state.current_url}".lower()
+ return state.page_type == "email_otp_verification" or "email-verification" in target or "email-otp" in target
+
+ def _state_is_add_phone(self, state: FlowState):
+ target = f"{state.continue_url} {state.current_url}".lower()
+ return state.page_type == "add_phone" or "add-phone" in target
+
+ def _state_requires_navigation(self, state: FlowState):
+ method = (state.method or "GET").upper()
+ if method != "GET":
+ return False
+ if (
+ state.source == "api"
+ and state.current_url
+ and state.page_type not in {"login_password", "email_otp_verification"}
+ ):
+ return True
+ if state.page_type == "external_url" and state.continue_url:
+ return True
+ if state.continue_url and state.continue_url != state.current_url:
+ return True
+ return False
+
+ def _state_supports_workspace_resolution(self, state: FlowState):
+ target = f"{state.continue_url} {state.current_url}".lower()
+ if state.page_type in {"consent", "workspace_selection", "organization_selection"}:
+ return True
+ if any(marker in target for marker in ("sign-in-with-chatgpt", "consent", "workspace", "organization")):
+ return True
+ session_data = self._decode_oauth_session_cookie() or {}
+ return bool(session_data.get("workspaces"))
+
+ def _follow_flow_state(self, state: FlowState, referer=None, user_agent=None, impersonate=None, max_hops=16):
+ """跟随服务端返回的 continue_url / current_url,返回新的状态或 authorization code。"""
+ import re
+
+ current_url = state.continue_url or state.current_url
+ last_url = current_url or ""
+ referer_url = referer
+
+ if not current_url:
+ return None, state
+
+ initial_code = self._extract_code_from_url(current_url)
+ if initial_code:
+ return initial_code, self._state_from_url(current_url)
+
+ for hop in range(max_hops):
+ try:
+ headers = self._headers(
+ current_url,
+ user_agent=user_agent,
+ accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ referer=referer_url,
+ navigation=True,
+ )
+ kwargs = {"headers": headers, "allow_redirects": False, "timeout": 30}
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+
+ self._browser_pause(0.12, 0.3)
+ r = self.session.get(current_url, **kwargs)
+ last_url = str(r.url)
+ self._log(f"follow[{hop + 1}] {r.status_code} {last_url[:120]}")
+ except Exception as e:
+ maybe_localhost = re.search(r'(https?://localhost[^\s\'\"]+)', str(e))
+ if maybe_localhost:
+ location = maybe_localhost.group(1)
+ code = self._extract_code_from_url(location)
+ if code:
+ self._log("从 localhost 异常提取到 authorization code")
+ return code, self._state_from_url(location)
+ self._log(f"follow[{hop + 1}] 异常: {str(e)[:160]}")
+ return None, self._state_from_url(last_url or current_url)
+
+ code = self._extract_code_from_url(last_url)
+ if code:
+ return code, self._state_from_url(last_url)
+
+ if r.status_code in (301, 302, 303, 307, 308):
+ location = normalize_flow_url(r.headers.get("Location", ""), auth_base=self.oauth_issuer)
+ if not location:
+ return None, self._state_from_url(last_url or current_url)
+ code = self._extract_code_from_url(location)
+ if code:
+ return code, self._state_from_url(location)
+ referer_url = last_url or referer_url
+ current_url = location
+ continue
+
+ content_type = (r.headers.get("content-type", "") or "").lower()
+ if "application/json" in content_type:
+ try:
+ next_state = self._state_from_payload(r.json(), current_url=last_url or current_url)
+ except Exception:
+ next_state = self._state_from_url(last_url or current_url)
+ else:
+ next_state = self._state_from_url(last_url or current_url)
+
+ return None, next_state
+
+ return None, self._state_from_url(last_url or current_url)
+
+ def _bootstrap_oauth_session(self, authorize_url, authorize_params, device_id=None, user_agent=None, sec_ch_ua=None, impersonate=None):
+ """启动 OAuth 会话,确保 auth 域上的 login_session 已建立。"""
+ if device_id:
+ seed_oai_device_cookie(self.session, device_id)
+
+ has_login_session = False
+ authorize_final_url = ""
+
+ try:
+ headers = self._headers(
+ authorize_url,
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ referer="https://chatgpt.com/",
+ navigation=True,
+ )
+ kwargs = {"params": authorize_params, "headers": headers, "allow_redirects": True, "timeout": 30}
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+
+ self._browser_pause()
+ r = self.session.get(authorize_url, **kwargs)
+ authorize_final_url = str(r.url)
+ redirects = len(getattr(r, "history", []) or [])
+ self._log(f"/oauth/authorize -> {r.status_code}, redirects={redirects}")
+
+ has_login_session = any(
+ (cookie.name if hasattr(cookie, "name") else str(cookie)) == "login_session"
+ for cookie in self.session.cookies
+ )
+ self._log(f"login_session: {'已获取' if has_login_session else '未获取'}")
+ except Exception as e:
+ self._log(f"/oauth/authorize 异常: {e}")
+
+ if has_login_session:
+ return authorize_final_url
+
+ self._log("未获取到 login_session,尝试 /api/oauth/oauth2/auth...")
+ try:
+ oauth2_url = f"{self.oauth_issuer}/api/oauth/oauth2/auth"
+ kwargs = {
+ "params": authorize_params,
+ "headers": self._headers(
+ oauth2_url,
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ referer="https://chatgpt.com/",
+ navigation=True,
+ ),
+ "allow_redirects": True,
+ "timeout": 30,
+ }
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+
+ self._browser_pause()
+ r2 = self.session.get(oauth2_url, **kwargs)
+ authorize_final_url = str(r2.url)
+ redirects2 = len(getattr(r2, "history", []) or [])
+ self._log(f"/api/oauth/oauth2/auth -> {r2.status_code}, redirects={redirects2}")
+
+ has_login_session = any(
+ (cookie.name if hasattr(cookie, "name") else str(cookie)) == "login_session"
+ for cookie in self.session.cookies
+ )
+ self._log(f"login_session(重试): {'已获取' if has_login_session else '未获取'}")
+ except Exception as e:
+ self._log(f"/api/oauth/oauth2/auth 异常: {e}")
+
+ return authorize_final_url
+
+ def _submit_authorize_continue(
+ self,
+ email,
+ device_id,
+ continue_referer,
+ *,
+ user_agent=None,
+ sec_ch_ua=None,
+ impersonate=None,
+ authorize_url=None,
+ authorize_params=None,
+ ):
+ """提交邮箱,获取 OAuth 流程的第一页状态。"""
+ self._log("步骤2: POST /api/accounts/authorize/continue")
+
+ sentinel_token = build_sentinel_token(
+ self.session,
+ device_id,
+ flow="authorize_continue",
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ impersonate=impersonate,
+ )
+ if not sentinel_token:
+ self._set_error("无法获取 sentinel token (authorize_continue)")
+ return None
+
+ request_url = f"{self.oauth_issuer}/api/accounts/authorize/continue"
+ headers = self._headers(
+ request_url,
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ accept="application/json",
+ referer=continue_referer,
+ origin=self.oauth_issuer,
+ content_type="application/json",
+ fetch_site="same-origin",
+ extra_headers={
+ "oai-device-id": device_id,
+ "openai-sentinel-token": sentinel_token,
+ },
+ )
+ headers.update(generate_datadog_trace())
+ payload = {"username": {"kind": "email", "value": email}}
+
+ try:
+ kwargs = {"json": payload, "headers": headers, "timeout": 30, "allow_redirects": False}
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+
+ self._browser_pause()
+ r = self.session.post(request_url, **kwargs)
+ self._log(f"/authorize/continue -> {r.status_code}")
+
+ if r.status_code == 400 and "invalid_auth_step" in (r.text or "") and authorize_url and authorize_params:
+ self._log("invalid_auth_step,重新 bootstrap...")
+ authorize_final_url = self._bootstrap_oauth_session(
+ authorize_url,
+ authorize_params,
+ device_id=device_id,
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ impersonate=impersonate,
+ )
+ continue_referer = (
+ authorize_final_url
+ if authorize_final_url.startswith(self.oauth_issuer)
+ else f"{self.oauth_issuer}/log-in"
+ )
+ headers["Referer"] = continue_referer
+ headers["Sec-Fetch-Site"] = "same-origin"
+ headers.update(generate_datadog_trace())
+ kwargs = {"json": payload, "headers": headers, "timeout": 30, "allow_redirects": False}
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+ self._browser_pause()
+ r = self.session.post(request_url, **kwargs)
+ self._log(f"/authorize/continue(重试) -> {r.status_code}")
+
+ if r.status_code != 200:
+ self._set_error(f"提交邮箱失败: {r.status_code} - {r.text[:180]}")
+ return None
+
+ data = r.json()
+ flow_state = self._state_from_payload(data, current_url=str(r.url) or request_url)
+ self._log(describe_flow_state(flow_state))
+ return flow_state
+ except Exception as e:
+ self._set_error(f"提交邮箱异常: {e}")
+ return None
+
+ def _submit_password_verify(self, password, device_id, *, user_agent=None, sec_ch_ua=None, impersonate=None, referer=None):
+ """提交密码,获取下一步状态。"""
+ self._log("步骤3: POST /api/accounts/password/verify")
+
+ sentinel_pwd = build_sentinel_token(
+ self.session,
+ device_id,
+ flow="password_verify",
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ impersonate=impersonate,
+ )
+ if not sentinel_pwd:
+ self._set_error("无法获取 sentinel token (password_verify)")
+ return None
+
+ request_url = f"{self.oauth_issuer}/api/accounts/password/verify"
+ headers = self._headers(
+ request_url,
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ accept="application/json",
+ referer=referer or f"{self.oauth_issuer}/log-in/password",
+ origin=self.oauth_issuer,
+ content_type="application/json",
+ fetch_site="same-origin",
+ extra_headers={
+ "oai-device-id": device_id,
+ "openai-sentinel-token": sentinel_pwd,
+ },
+ )
+ headers.update(generate_datadog_trace())
+
+ try:
+ kwargs = {"json": {"password": password}, "headers": headers, "timeout": 30, "allow_redirects": False}
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+
+ self._browser_pause()
+ r = self.session.post(request_url, **kwargs)
+ self._log(f"/password/verify -> {r.status_code}")
+
+ if r.status_code != 200:
+ self._set_error(f"密码验证失败: {r.status_code} - {r.text[:180]}")
+ return None
+
+ data = r.json()
+ flow_state = self._state_from_payload(data, current_url=str(r.url) or request_url)
+ self._log(f"verify {describe_flow_state(flow_state)}")
+ return flow_state
+ except Exception as e:
+ self._set_error(f"密码验证异常: {e}")
+ return None
+
+ def login_and_get_tokens(self, email, password, device_id, user_agent=None, sec_ch_ua=None, impersonate=None, skymail_client=None):
+ """
+ 完整的 OAuth 登录流程,获取 tokens
+
+ Args:
+ email: 邮箱
+ password: 密码
+ device_id: 设备 ID
+ user_agent: User-Agent
+ sec_ch_ua: sec-ch-ua header
+ impersonate: curl_cffi impersonate 参数
+ skymail_client: Skymail 客户端(用于获取 OTP,如果需要)
+
+ Returns:
+ dict: tokens 字典,包含 access_token, refresh_token, id_token
+ """
+ self.last_error = ""
+ self._log("开始 OAuth 登录流程...")
+
+ code_verifier, code_challenge = generate_pkce()
+ oauth_state = secrets.token_urlsafe(32)
+ authorize_params = {
+ "response_type": "code",
+ "client_id": self.oauth_client_id,
+ "redirect_uri": self.oauth_redirect_uri,
+ "scope": "openid profile email offline_access",
+ "code_challenge": code_challenge,
+ "code_challenge_method": "S256",
+ "state": oauth_state,
+ }
+ authorize_url = f"{self.oauth_issuer}/oauth/authorize"
+
+ seed_oai_device_cookie(self.session, device_id)
+
+ self._log("步骤1: Bootstrap OAuth session...")
+ authorize_final_url = self._bootstrap_oauth_session(
+ authorize_url,
+ authorize_params,
+ device_id=device_id,
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ impersonate=impersonate,
+ )
+ if not authorize_final_url:
+ self._set_error("Bootstrap 失败")
+ return None
+
+ continue_referer = (
+ authorize_final_url
+ if authorize_final_url.startswith(self.oauth_issuer)
+ else f"{self.oauth_issuer}/log-in"
+ )
+
+ state = self._submit_authorize_continue(
+ email,
+ device_id,
+ continue_referer,
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ impersonate=impersonate,
+ authorize_url=authorize_url,
+ authorize_params=authorize_params,
+ )
+ if not state:
+ if not self.last_error:
+ self._set_error("提交邮箱后未进入有效的 OAuth 状态")
+ return None
+
+ self._log(f"OAuth 状态起点: {describe_flow_state(state)}")
+ seen_states = {}
+ referer = continue_referer
+
+ for step in range(20):
+ signature = self._state_signature(state)
+ seen_states[signature] = seen_states.get(signature, 0) + 1
+ if seen_states[signature] > 2:
+ self._set_error(f"OAuth 状态卡住: {describe_flow_state(state)}")
+ return None
+
+ code = self._extract_code_from_state(state)
+ if code:
+ self._log(f"获取到 authorization code: {code[:20]}...")
+ self._log("步骤7: POST /oauth/token")
+ tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate)
+ if tokens:
+ self._log("✅ OAuth 登录成功")
+ else:
+ self._log("换取 tokens 失败")
+ return tokens
+
+ if self._state_is_login_password(state):
+ next_state = self._submit_password_verify(
+ password,
+ device_id,
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ impersonate=impersonate,
+ referer=state.current_url or state.continue_url or referer,
+ )
+ if not next_state:
+ if not self.last_error:
+ self._set_error("密码验证后未进入下一步 OAuth 状态")
+ return None
+ referer = state.current_url or referer
+ state = next_state
+ continue
+
+ if self._state_is_email_otp(state):
+ if not skymail_client:
+ self._set_error("当前流程需要邮箱 OTP,但缺少接码客户端")
+ return None
+ next_state = self._handle_otp_verification(
+ email,
+ device_id,
+ user_agent,
+ sec_ch_ua,
+ impersonate,
+ skymail_client,
+ state,
+ )
+ if not next_state:
+ if not self.last_error:
+ self._set_error("邮箱 OTP 验证后未进入下一步 OAuth 状态")
+ return None
+ referer = state.current_url or referer
+ state = next_state
+ continue
+
+ if self._state_is_add_phone(state):
+ next_state = self._handle_add_phone_verification(
+ device_id,
+ user_agent,
+ sec_ch_ua,
+ impersonate,
+ state,
+ )
+ if not next_state:
+ if not self.last_error:
+ self._set_error("手机号验证后未进入下一步 OAuth 状态")
+ return None
+ referer = state.current_url or referer
+ state = next_state
+ continue
+
+ if self._state_requires_navigation(state):
+ code, next_state = self._follow_flow_state(
+ state,
+ referer=referer,
+ user_agent=user_agent,
+ impersonate=impersonate,
+ )
+ if code:
+ self._log(f"获取到 authorization code: {code[:20]}...")
+ self._log("步骤7: POST /oauth/token")
+ tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate)
+ if tokens:
+ self._log("✅ OAuth 登录成功")
+ else:
+ self._log("换取 tokens 失败")
+ return tokens
+ referer = state.current_url or referer
+ state = next_state
+ self._log(f"follow state -> {describe_flow_state(state)}")
+ continue
+
+ if self._state_supports_workspace_resolution(state):
+ self._log("步骤6: 执行 workspace/org 选择")
+ code, next_state = self._oauth_submit_workspace_and_org(
+ state.continue_url or state.current_url or f"{self.oauth_issuer}/sign-in-with-chatgpt/codex/consent",
+ device_id,
+ user_agent,
+ impersonate,
+ )
+ if code:
+ self._log(f"获取到 authorization code: {code[:20]}...")
+ self._log("步骤7: POST /oauth/token")
+ tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate)
+ if tokens:
+ self._log("✅ OAuth 登录成功")
+ else:
+ self._log("换取 tokens 失败")
+ return tokens
+ if next_state:
+ referer = state.current_url or referer
+ state = next_state
+ self._log(f"workspace state -> {describe_flow_state(state)}")
+ continue
+
+ if not self.last_error:
+ self._set_error(f"workspace/org 选择失败: {describe_flow_state(state)}")
+ return None
+
+ self._set_error(f"未支持的 OAuth 状态: {describe_flow_state(state)}")
+ return None
+
+ self._set_error("OAuth 状态机超出最大步数")
+ return None
+
+ def _extract_code_from_url(self, url):
+ """从 URL 中提取 code"""
+ if not url or "code=" not in url:
+ return None
+ try:
+ return parse_qs(urlparse(url).query).get("code", [None])[0]
+ except Exception:
+ return None
+
+ def _oauth_follow_for_code(self, start_url, referer, user_agent, impersonate, max_hops=16):
+ """跟随 URL 获取 authorization code(手动跟随重定向)"""
+ code, next_state = self._follow_flow_state(
+ self._state_from_url(start_url),
+ referer=referer,
+ user_agent=user_agent,
+ impersonate=impersonate,
+ max_hops=max_hops,
+ )
+ return code, (next_state.current_url or next_state.continue_url or start_url)
+
+ def _oauth_submit_workspace_and_org(self, consent_url, device_id, user_agent, impersonate, max_retries=3):
+ """提交 workspace 和 organization 选择(带重试)"""
+ session_data = None
+
+ for attempt in range(max_retries):
+ session_data = self._load_workspace_session_data(
+ consent_url=consent_url,
+ user_agent=user_agent,
+ impersonate=impersonate,
+ )
+ if session_data:
+ break
+
+ if attempt < max_retries - 1:
+ self._log(f"无法获取 consent session 数据 (尝试 {attempt + 1}/{max_retries})")
+ time.sleep(0.3)
+ else:
+ self._set_error("无法获取 consent session 数据")
+ return None, None
+
+ workspaces = session_data.get("workspaces", [])
+ if not workspaces:
+ self._set_error("session 中没有 workspace 信息")
+ return None, None
+
+ workspace_id = (workspaces[0] or {}).get("id")
+ if not workspace_id:
+ self._set_error("workspace_id 为空")
+ return None, None
+
+ self._log(f"选择 workspace: {workspace_id}")
+
+ headers = self._headers(
+ f"{self.oauth_issuer}/api/accounts/workspace/select",
+ user_agent=user_agent,
+ accept="application/json",
+ referer=consent_url,
+ origin=self.oauth_issuer,
+ content_type="application/json",
+ fetch_site="same-origin",
+ extra_headers={
+ "oai-device-id": device_id,
+ },
+ )
+ headers.update(generate_datadog_trace())
+
+ try:
+ kwargs = {
+ "json": {"workspace_id": workspace_id},
+ "headers": headers,
+ "allow_redirects": False,
+ "timeout": 30
+ }
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+
+ self._browser_pause()
+ r = self.session.post(
+ f"{self.oauth_issuer}/api/accounts/workspace/select",
+ **kwargs
+ )
+
+ self._log(f"workspace/select -> {r.status_code}")
+
+ # 检查重定向
+ if r.status_code in (301, 302, 303, 307, 308):
+ location = normalize_flow_url(r.headers.get("Location", ""), auth_base=self.oauth_issuer)
+ if "code=" in location:
+ code = self._extract_code_from_url(location)
+ if code:
+ self._log("从 workspace/select 重定向获取到 code")
+ return code, self._state_from_url(location)
+ if location:
+ return None, self._state_from_url(location)
+
+ # 如果返回 200,检查响应中的 orgs
+ if r.status_code == 200:
+ try:
+ data = r.json()
+ orgs = data.get("data", {}).get("orgs", [])
+ workspace_state = self._state_from_payload(data, current_url=str(r.url))
+ continue_url = workspace_state.continue_url
+
+ if orgs:
+ org_id = (orgs[0] or {}).get("id")
+ projects = (orgs[0] or {}).get("projects", [])
+ project_id = (projects[0] or {}).get("id") if projects else None
+
+ if org_id:
+ self._log(f"选择 organization: {org_id}")
+
+ org_body = {"org_id": org_id}
+ if project_id:
+ org_body["project_id"] = project_id
+
+ org_referer = continue_url if continue_url and continue_url.startswith("http") else consent_url
+ headers = self._headers(
+ f"{self.oauth_issuer}/api/accounts/organization/select",
+ user_agent=user_agent,
+ accept="application/json",
+ referer=org_referer,
+ origin=self.oauth_issuer,
+ content_type="application/json",
+ fetch_site="same-origin",
+ extra_headers={
+ "oai-device-id": device_id,
+ },
+ )
+ headers.update(generate_datadog_trace())
+
+ kwargs = {
+ "json": org_body,
+ "headers": headers,
+ "allow_redirects": False,
+ "timeout": 30
+ }
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+
+ self._browser_pause()
+ r_org = self.session.post(
+ f"{self.oauth_issuer}/api/accounts/organization/select",
+ **kwargs
+ )
+
+ self._log(f"organization/select -> {r_org.status_code}")
+
+ # 检查重定向
+ if r_org.status_code in (301, 302, 303, 307, 308):
+ location = normalize_flow_url(r_org.headers.get("Location", ""), auth_base=self.oauth_issuer)
+ if "code=" in location:
+ code = self._extract_code_from_url(location)
+ if code:
+ self._log("从 organization/select 重定向获取到 code")
+ return code, self._state_from_url(location)
+ if location:
+ return None, self._state_from_url(location)
+
+ # 检查 continue_url
+ if r_org.status_code == 200:
+ try:
+ org_state = self._state_from_payload(r_org.json(), current_url=str(r_org.url))
+ self._log(f"organization/select -> {describe_flow_state(org_state)}")
+ if self._extract_code_from_state(org_state):
+ return self._extract_code_from_state(org_state), org_state
+ return None, org_state
+ except Exception as e:
+ self._set_error(f"解析 organization/select 响应异常: {e}")
+
+ # 如果有 continue_url,跟随它
+ if continue_url:
+ code, _ = self._oauth_follow_for_code(continue_url, consent_url, user_agent, impersonate)
+ if code:
+ return code, self._state_from_url(continue_url)
+ return None, workspace_state
+
+ except Exception as e:
+ self._set_error(f"处理 workspace/select 响应异常: {e}")
+ return None, None
+
+ except Exception as e:
+ self._set_error(f"workspace/select 异常: {e}")
+ return None, None
+
+ return None, None
+
+ def _load_workspace_session_data(self, consent_url, user_agent, impersonate):
+ """优先从 cookie 解码 session,失败时回退到 consent HTML 中提取 workspace 数据。"""
+ session_data = self._decode_oauth_session_cookie()
+ if session_data and session_data.get("workspaces"):
+ return session_data
+
+ html = self._fetch_consent_page_html(consent_url, user_agent, impersonate)
+ if not html:
+ return session_data
+
+ parsed = self._extract_session_data_from_consent_html(html)
+ if parsed and parsed.get("workspaces"):
+ self._log(f"从 consent HTML 提取到 {len(parsed.get('workspaces', []))} 个 workspace")
+ return parsed
+
+ return session_data
+
+ def _fetch_consent_page_html(self, consent_url, user_agent, impersonate):
+ """获取 consent 页 HTML,用于解析 React Router stream 中的 session 数据。"""
+ try:
+ headers = self._headers(
+ consent_url,
+ user_agent=user_agent,
+ accept="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ referer=f"{self.oauth_issuer}/email-verification",
+ navigation=True,
+ )
+ kwargs = {"headers": headers, "allow_redirects": False, "timeout": 30}
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+ self._browser_pause(0.12, 0.3)
+ r = self.session.get(consent_url, **kwargs)
+ if r.status_code == 200 and "text/html" in (r.headers.get("content-type", "").lower()):
+ return r.text
+ except Exception:
+ pass
+ return ""
+
+ def _extract_session_data_from_consent_html(self, html):
+ """从 consent HTML 的 React Router stream 中提取 workspace session 数据。"""
+ import json
+ import re
+
+ if not html or "workspaces" not in html:
+ return None
+
+ def _first_match(patterns, text):
+ for pattern in patterns:
+ m = re.search(pattern, text, re.S)
+ if m:
+ return m.group(1)
+ return ""
+
+ def _build_from_text(text):
+ if not text or "workspaces" not in text:
+ return None
+
+ normalized = text.replace('\\"', '"')
+
+ session_id = _first_match(
+ [
+ r'"session_id","([^"]+)"',
+ r'"session_id":"([^"]+)"',
+ ],
+ normalized,
+ )
+ client_id = _first_match(
+ [
+ r'"openai_client_id","([^"]+)"',
+ r'"openai_client_id":"([^"]+)"',
+ ],
+ normalized,
+ )
+
+ start = normalized.find('"workspaces"')
+ if start < 0:
+ start = normalized.find('workspaces')
+ if start < 0:
+ return None
+
+ end = normalized.find('"openai_client_id"', start)
+ if end < 0:
+ end = normalized.find('openai_client_id', start)
+ if end < 0:
+ end = min(len(normalized), start + 4000)
+ else:
+ end = min(len(normalized), end + 600)
+
+ workspace_chunk = normalized[start:end]
+ ids = re.findall(r'"id"(?:,|:)"([0-9a-fA-F-]{36})"', workspace_chunk)
+ if not ids:
+ return None
+
+ kinds = re.findall(r'"kind"(?:,|:)"([^"]+)"', workspace_chunk)
+ workspaces = []
+ seen = set()
+ for idx, wid in enumerate(ids):
+ if wid in seen:
+ continue
+ seen.add(wid)
+ item = {"id": wid}
+ if idx < len(kinds):
+ item["kind"] = kinds[idx]
+ workspaces.append(item)
+
+ if not workspaces:
+ return None
+
+ return {
+ "session_id": session_id,
+ "openai_client_id": client_id,
+ "workspaces": workspaces,
+ }
+
+ candidates = [html]
+
+ for quoted in re.findall(
+ r'streamController\.enqueue\(("(?:\\.|[^"\\])*")\)',
+ html,
+ re.S,
+ ):
+ try:
+ decoded = json.loads(quoted)
+ except Exception:
+ continue
+ if decoded:
+ candidates.append(decoded)
+
+ if '\\"' in html:
+ candidates.append(html.replace('\\"', '"'))
+
+ for candidate in candidates:
+ parsed = _build_from_text(candidate)
+ if parsed and parsed.get("workspaces"):
+ return parsed
+
+ return None
+
+ def _decode_oauth_session_cookie(self):
+ """解码 oai-client-auth-session cookie"""
+ try:
+ for cookie in self.session.cookies:
+ try:
+ name = cookie.name if hasattr(cookie, 'name') else str(cookie)
+ if name == "oai-client-auth-session":
+ value = cookie.value if hasattr(cookie, 'value') else self.session.cookies.get(name)
+ if value:
+ data = self._decode_cookie_json_value(value)
+ if data:
+ return data
+ except Exception:
+ continue
+ except Exception:
+ pass
+
+ return None
+
+ @staticmethod
+ def _decode_cookie_json_value(value):
+ import base64
+ import json
+
+ raw_value = str(value or "").strip()
+ if not raw_value:
+ return None
+
+ candidates = [raw_value]
+ if "." in raw_value:
+ candidates.insert(0, raw_value.split(".", 1)[0])
+
+ for candidate in candidates:
+ candidate = candidate.strip()
+ if not candidate:
+ continue
+ padded = candidate + "=" * (-len(candidate) % 4)
+ for decoder in (base64.urlsafe_b64decode, base64.b64decode):
+ try:
+ decoded = decoder(padded).decode("utf-8")
+ parsed = json.loads(decoded)
+ except Exception:
+ continue
+ if isinstance(parsed, dict):
+ return parsed
+
+ return None
+
+ def _exchange_code_for_tokens(self, code, code_verifier, user_agent, impersonate):
+ """用 authorization code 换取 tokens"""
+ url = f"{self.oauth_issuer}/oauth/token"
+
+ payload = {
+ "grant_type": "authorization_code",
+ "code": code,
+ "redirect_uri": self.oauth_redirect_uri,
+ "client_id": self.oauth_client_id,
+ "code_verifier": code_verifier,
+ }
+
+ headers = self._headers(
+ url,
+ user_agent=user_agent,
+ accept="application/json",
+ referer=f"{self.oauth_issuer}/sign-in-with-chatgpt/codex/consent",
+ origin=self.oauth_issuer,
+ content_type="application/x-www-form-urlencoded",
+ fetch_site="same-origin",
+ )
+
+ try:
+ kwargs = {"data": payload, "headers": headers, "timeout": 60}
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+
+ self._browser_pause()
+ r = self.session.post(url, **kwargs)
+
+ if r.status_code == 200:
+ return r.json()
+ else:
+ self._set_error(f"换取 tokens 失败: {r.status_code} - {r.text[:200]}")
+
+ except Exception as e:
+ self._set_error(f"换取 tokens 异常: {e}")
+
+ return None
+
+ def _send_phone_number(self, phone, device_id, user_agent, sec_ch_ua, impersonate):
+ request_url = f"{self.oauth_issuer}/api/accounts/add-phone/send"
+ headers = self._headers(
+ request_url,
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ accept="application/json",
+ referer=f"{self.oauth_issuer}/add-phone",
+ origin=self.oauth_issuer,
+ content_type="application/json",
+ fetch_site="same-origin",
+ extra_headers={"oai-device-id": device_id},
+ )
+ headers.update(generate_datadog_trace())
+
+ try:
+ kwargs = {
+ "json": {"phone_number": phone},
+ "headers": headers,
+ "timeout": 30,
+ "allow_redirects": False,
+ }
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+
+ self._browser_pause(0.12, 0.25)
+ resp = self.session.post(request_url, **kwargs)
+ except Exception as e:
+ return False, None, f"add-phone/send 异常: {e}"
+
+ self._log(f"/add-phone/send -> {resp.status_code}")
+ if resp.status_code != 200:
+ return False, None, f"add-phone/send 失败: {resp.status_code} - {resp.text[:180]}"
+
+ try:
+ data = resp.json()
+ except Exception:
+ return False, None, "add-phone/send 响应不是 JSON"
+
+ next_state = self._state_from_payload(data, current_url=str(resp.url) or request_url)
+ self._log(f"add-phone/send {describe_flow_state(next_state)}")
+ return True, next_state, ""
+
+ def _resend_phone_otp(self, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState):
+ request_url = f"{self.oauth_issuer}/api/accounts/phone-otp/resend"
+ headers = self._headers(
+ request_url,
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ accept="application/json",
+ referer=state.current_url or state.continue_url or f"{self.oauth_issuer}/phone-verification",
+ origin=self.oauth_issuer,
+ fetch_site="same-origin",
+ extra_headers={"oai-device-id": device_id},
+ )
+ headers.update(generate_datadog_trace())
+
+ try:
+ kwargs = {"headers": headers, "timeout": 30, "allow_redirects": False}
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+ self._browser_pause(0.12, 0.25)
+ resp = self.session.post(request_url, **kwargs)
+ except Exception as e:
+ return False, f"phone-otp/resend 异常: {e}"
+
+ self._log(f"/phone-otp/resend -> {resp.status_code}")
+ if resp.status_code == 200:
+ return True, ""
+ return False, f"phone-otp/resend 失败: {resp.status_code} - {resp.text[:180]}"
+
+ def _validate_phone_otp(self, code, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState):
+ request_url = f"{self.oauth_issuer}/api/accounts/phone-otp/validate"
+ headers = self._headers(
+ request_url,
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ accept="application/json",
+ referer=state.current_url or state.continue_url or f"{self.oauth_issuer}/phone-verification",
+ origin=self.oauth_issuer,
+ content_type="application/json",
+ fetch_site="same-origin",
+ extra_headers={"oai-device-id": device_id},
+ )
+ headers.update(generate_datadog_trace())
+
+ try:
+ kwargs = {
+ "json": {"code": code},
+ "headers": headers,
+ "timeout": 30,
+ "allow_redirects": False,
+ }
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+ self._browser_pause(0.12, 0.25)
+ resp = self.session.post(request_url, **kwargs)
+ except Exception as e:
+ return False, None, f"phone-otp/validate 异常: {e}"
+
+ self._log(f"/phone-otp/validate -> {resp.status_code}")
+ if resp.status_code != 200:
+ if resp.status_code == 401:
+ return False, None, "手机号验证码错误"
+ return False, None, f"phone-otp/validate 失败: {resp.status_code} - {resp.text[:180]}"
+
+ try:
+ data = resp.json()
+ except Exception:
+ return False, None, "phone-otp/validate 响应不是 JSON"
+
+ next_state = self._state_from_payload(data, current_url=str(resp.url) or request_url)
+ self._log(f"手机号 OTP 验证通过 {describe_flow_state(next_state)}")
+ return True, next_state, ""
+
+ def _handle_add_phone_verification(self, device_id, user_agent, sec_ch_ua, impersonate, state: FlowState):
+ """
+ add_phone 阶段处理(已禁用自动手机号验证)。
+ 按要求:不将手机号验证失败视为硬失败,记录状态后交由上层处理。
+ """
+ self._set_error("add_phone_required")
+ return None
+
+ def _handle_otp_verification(self, email, device_id, user_agent, sec_ch_ua, impersonate, skymail_client, state):
+ """处理 OAuth 阶段的邮箱 OTP 验证,返回服务端声明的下一步状态。"""
+ self._log("步骤4: 检测到邮箱 OTP 验证")
+
+ request_url = f"{self.oauth_issuer}/api/accounts/email-otp/validate"
+ headers_otp = self._headers(
+ request_url,
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ accept="application/json",
+ referer=state.current_url or state.continue_url or f"{self.oauth_issuer}/email-verification",
+ origin=self.oauth_issuer,
+ content_type="application/json",
+ fetch_site="same-origin",
+ extra_headers={
+ "oai-device-id": device_id,
+ },
+ )
+ headers_otp.update(generate_datadog_trace())
+
+ if not hasattr(skymail_client, "_used_codes"):
+ skymail_client._used_codes = set()
+
+ tried_codes = set(getattr(skymail_client, "_used_codes", set()))
+ otp_deadline = time.time() + 60
+ otp_sent_at = time.time()
+
+ def validate_otp(code):
+ tried_codes.add(code)
+ self._log(f"尝试 OTP: {code}")
+
+ try:
+ kwargs = {
+ "json": {"code": code},
+ "headers": headers_otp,
+ "timeout": 30,
+ "allow_redirects": False,
+ }
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+
+ self._browser_pause(0.12, 0.25)
+ resp_otp = self.session.post(request_url, **kwargs)
+ except Exception as e:
+ self._log(f"email-otp/validate 异常: {e}")
+ return None
+
+ self._log(f"/email-otp/validate -> {resp_otp.status_code}")
+ if resp_otp.status_code != 200:
+ self._log(f"OTP 无效: {resp_otp.text[:160]}")
+ return None
+
+ try:
+ otp_data = resp_otp.json()
+ except Exception:
+ self._log("email-otp/validate 响应不是 JSON")
+ return None
+
+ next_state = self._state_from_payload(
+ otp_data,
+ current_url=str(resp_otp.url) or (state.current_url or state.continue_url or request_url),
+ )
+ self._log(f"OTP 验证通过 {describe_flow_state(next_state)}")
+ skymail_client._used_codes.add(code)
+ return next_state
+
+ if hasattr(skymail_client, "wait_for_verification_code"):
+ self._log("使用 wait_for_verification_code 进行阻塞式获取新验证码...")
+ while time.time() < otp_deadline:
+ remaining = max(1, int(otp_deadline - time.time()))
+ wait_time = min(10, remaining)
+ try:
+ code = skymail_client.wait_for_verification_code(
+ email,
+ timeout=wait_time,
+ otp_sent_at=otp_sent_at,
+ exclude_codes=tried_codes,
+ )
+ except Exception as e:
+ self._log(f"等待 OTP 异常: {e}")
+ code = None
+
+ if not code:
+ self._log("暂未收到新的 OTP,继续等待...")
+ if self.last_error:
+ break
+ continue
+
+ if code in tried_codes:
+ self._log(f"跳过已尝试验证码: {code}")
+ continue
+
+ next_state = validate_otp(code)
+ if next_state:
+ return next_state
+ if self.last_error:
+ break
+ else:
+ while time.time() < otp_deadline:
+ messages = skymail_client.fetch_emails(email) or []
+ candidate_codes = []
+
+ for msg in messages[:12]:
+ content = msg.get("content") or msg.get("text") or ""
+ code = skymail_client.extract_verification_code(content)
+ if code and code not in tried_codes:
+ candidate_codes.append(code)
+
+ if not candidate_codes:
+ elapsed = int(60 - max(0, otp_deadline - time.time()))
+ self._log(f"等待新的 OTP... ({elapsed}s/60s)")
+ time.sleep(2)
+ continue
+
+ for otp_code in candidate_codes:
+ next_state = validate_otp(otp_code)
+ if next_state:
+ return next_state
+
+ time.sleep(2)
+ if self.last_error:
+ break
+
+ if not self.last_error:
+ self._set_error(f"OAuth 阶段 OTP 验证失败,已尝试 {len(tried_codes)} 个验证码")
+ return None
diff --git a/src/core/anyauto/register_flow.py b/src/core/anyauto/register_flow.py
new file mode 100644
index 00000000..78196f78
--- /dev/null
+++ b/src/core/anyauto/register_flow.py
@@ -0,0 +1,313 @@
+"""
+Any-auto-register 风格注册流程(V2)。
+以状态机 + Session 复用为主,必要时回退 OAuth。
+"""
+
+from __future__ import annotations
+
+import secrets
+import time
+from datetime import datetime
+from typing import Optional, Callable, Dict, Any
+
+from .chatgpt_client import ChatGPTClient
+from .oauth_client import OAuthClient
+from .utils import generate_random_name, generate_random_birthday
+from ...config.constants import PASSWORD_CHARSET, DEFAULT_PASSWORD_LENGTH
+from ...config.settings import get_settings
+
+
+class EmailServiceAdapter:
+ """将 codex-console 邮箱服务适配成 any-auto-register 预期接口。"""
+
+ def __init__(self, email_service, email: str, email_id: Optional[str], log_fn: Callable[[str], None]):
+ self.es = email_service
+ self.email = email
+ self.email_id = email_id
+ self.log_fn = log_fn or (lambda _msg: None)
+ self._used_codes: set[str] = set()
+
+ def wait_for_verification_code(self, email, timeout=60, otp_sent_at=None, exclude_codes=None):
+ exclude = set(exclude_codes or [])
+ exclude.update(self._used_codes)
+ deadline = time.time() + max(1, int(timeout))
+ while time.time() < deadline:
+ remaining = max(1, int(deadline - time.time()))
+ code = self.es.get_verification_code(
+ email=email,
+ email_id=self.email_id,
+ timeout=remaining,
+ otp_sent_at=otp_sent_at,
+ )
+ if not code:
+ return None
+ if code in exclude:
+ exclude.add(code)
+ continue
+ self._used_codes.add(code)
+ self.log_fn(f"成功获取验证码: {code}")
+ return code
+ return None
+
+
+class AnyAutoRegistrationEngine:
+ def __init__(
+ self,
+ email_service,
+ proxy_url: Optional[str] = None,
+ callback_logger: Optional[Callable[[str], None]] = None,
+ max_retries: int = 3,
+ browser_mode: str = "protocol",
+ extra_config: Optional[Dict[str, Any]] = None,
+ ):
+ self.email_service = email_service
+ self.proxy_url = proxy_url
+ self.callback_logger = callback_logger or (lambda _msg: None)
+ self.max_retries = max(1, int(max_retries or 1))
+ self.browser_mode = browser_mode or "protocol"
+ self.extra_config = dict(extra_config or {})
+
+ self.email: Optional[str] = None
+ self.inbox_email: Optional[str] = None
+ self.email_info: Optional[Dict[str, Any]] = None
+ self.password: Optional[str] = None
+ self.session = None
+ self.device_id: Optional[str] = None
+
+ def _log(self, message: str):
+ if self.callback_logger:
+ self.callback_logger(message)
+
+ @staticmethod
+ def _build_password(length: int) -> str:
+ length = max(8, int(length or DEFAULT_PASSWORD_LENGTH))
+ return "".join(secrets.choice(PASSWORD_CHARSET) for _ in range(length))
+
+ @staticmethod
+ def _should_retry(message: str) -> bool:
+ text = str(message or "").lower()
+ retriable_markers = [
+ "tls",
+ "ssl",
+ "curl: (35)",
+ "预授权被拦截",
+ "authorize",
+ "registration_disallowed",
+ "http 400",
+ "创建账号失败",
+ "未获取到 authorization code",
+ "consent",
+ "workspace",
+ "organization",
+ "otp",
+ "验证码",
+ "session",
+ "accesstoken",
+ "next-auth",
+ ]
+ return any(marker.lower() in text for marker in retriable_markers)
+
+ @staticmethod
+ def _is_phone_required_error(message: str) -> bool:
+ text = str(message or "").lower()
+ return any(
+ marker in text
+ for marker in (
+ "add_phone",
+ "add-phone",
+ "phone",
+ "phone required",
+ "phone verification",
+ "手机号",
+ )
+ )
+
+ def run(self):
+ """
+ 执行 any-auto-register 风格注册流程。
+ 返回 dict:包含 result(RegistrationResult 填充所需字段) + 额外上下文。
+ """
+ last_error = ""
+ settings = get_settings()
+ password_len = int(getattr(settings, "registration_default_password_length", DEFAULT_PASSWORD_LENGTH) or DEFAULT_PASSWORD_LENGTH)
+
+ oauth_config = dict(self.extra_config or {})
+ if not oauth_config:
+ oauth_config = {
+ "oauth_issuer": str(getattr(settings, "openai_auth_url", "") or "https://auth.openai.com"),
+ "oauth_client_id": str(getattr(settings, "openai_client_id", "") or "app_EMoamEEZ73f0CkXaXp7hrann"),
+ "oauth_redirect_uri": str(getattr(settings, "openai_redirect_uri", "") or "http://localhost:1455/auth/callback"),
+ }
+
+ for attempt in range(self.max_retries):
+ try:
+ if attempt == 0:
+ self._log("=" * 60)
+ self._log("开始注册流程 V2 (Session 复用直取 AccessToken)")
+ self._log(f"请求模式: {self.browser_mode}")
+ self._log("=" * 60)
+ else:
+ self._log(f"整流程重试 {attempt + 1}/{self.max_retries} ...")
+ time.sleep(1)
+
+ # 1. 创建邮箱
+ self.email_info = self.email_service.create_email()
+ raw_email = str((self.email_info or {}).get("email") or "").strip()
+ if not raw_email:
+ last_error = "创建邮箱失败"
+ return {"success": False, "error_message": last_error}
+
+ normalized_email = raw_email.lower()
+ self.inbox_email = raw_email
+ self.email = normalized_email
+ try:
+ self.email_info["email"] = normalized_email
+ except Exception:
+ pass
+
+ if raw_email != normalized_email:
+ self._log(f"邮箱规范化: {raw_email} -> {normalized_email}")
+
+ # 2. 生成密码 & 用户信息
+ self.password = self.password or self._build_password(password_len)
+ first_name, last_name = generate_random_name()
+ birthdate = generate_random_birthday()
+ self._log(f"邮箱: {normalized_email}, 密码: {self.password}")
+ self._log(f"注册信息: {first_name} {last_name}, 生日: {birthdate}")
+
+ # 3. 邮箱适配器
+ email_id = (self.email_info or {}).get("service_id")
+ skymail_adapter = EmailServiceAdapter(self.email_service, normalized_email, email_id, self._log)
+
+ # 4. 注册状态机
+ chatgpt_client = ChatGPTClient(
+ proxy=self.proxy_url,
+ verbose=False,
+ browser_mode=self.browser_mode,
+ )
+ chatgpt_client._log = self._log
+
+ self._log("步骤 1/2: 执行注册状态机...")
+ success, msg = chatgpt_client.register_complete_flow(
+ normalized_email, self.password, first_name, last_name, birthdate, skymail_adapter
+ )
+ if not success:
+ last_error = f"注册流失败: {msg}"
+ if attempt < self.max_retries - 1 and self._should_retry(msg):
+ self._log(f"注册流失败,准备整流程重试: {msg}")
+ continue
+ return {"success": False, "error_message": last_error}
+
+ # 保存会话与设备
+ self.session = chatgpt_client.session
+ self.device_id = chatgpt_client.device_id
+
+ # 5. 复用 session 取 token
+ self._log("步骤 2/2: 优先复用注册会话提取 ChatGPT Session / AccessToken...")
+ session_ok, session_result = chatgpt_client.reuse_session_and_get_tokens()
+ if session_ok:
+ self._log("Token 提取完成!")
+ return {
+ "success": True,
+ "access_token": session_result.get("access_token", ""),
+ "session_token": session_result.get("session_token", ""),
+ "account_id": session_result.get("account_id", "") or session_result.get("user_id", ""),
+ "workspace_id": session_result.get("workspace_id", ""),
+ "metadata": {
+ "auth_provider": session_result.get("auth_provider", ""),
+ "expires": session_result.get("expires", ""),
+ "user_id": session_result.get("user_id", ""),
+ "user": session_result.get("user") or {},
+ "account": session_result.get("account") or {},
+ },
+ }
+
+ # 6. OAuth 回退
+ self._log(f"复用会话失败,回退到 OAuth 登录补全流程: {session_result}")
+ tokens = None
+ oauth_client = None
+ for oauth_attempt in range(2):
+ if oauth_attempt > 0:
+ self._log(f"同账号 OAuth 重试 {oauth_attempt + 1}/2 ...")
+ time.sleep(1)
+
+ oauth_client = OAuthClient(
+ config=oauth_config,
+ proxy=self.proxy_url,
+ verbose=False,
+ browser_mode=self.browser_mode,
+ )
+ oauth_client._log = self._log
+ oauth_client.session = chatgpt_client.session
+
+ tokens = oauth_client.login_and_get_tokens(
+ normalized_email,
+ self.password,
+ chatgpt_client.device_id,
+ chatgpt_client.ua,
+ chatgpt_client.sec_ch_ua,
+ chatgpt_client.impersonate,
+ skymail_adapter,
+ )
+ if tokens and tokens.get("access_token"):
+ break
+
+ if oauth_client.last_error and "add_phone" in oauth_client.last_error:
+ break
+
+ if tokens and tokens.get("access_token"):
+ self._log("OAuth 回退补全成功!")
+ workspace_id = ""
+ session_cookie = ""
+ try:
+ session_data = oauth_client._decode_oauth_session_cookie()
+ if session_data:
+ workspaces = session_data.get("workspaces", [])
+ if workspaces:
+ workspace_id = str((workspaces[0] or {}).get("id") or "")
+ if workspace_id:
+ self._log(f"成功萃取 Workspace ID: {workspace_id}")
+ except Exception:
+ pass
+
+ try:
+ for cookie in oauth_client.session.cookies.jar:
+ if cookie.name == "__Secure-next-auth.session-token":
+ session_cookie = cookie.value
+ break
+ except Exception:
+ pass
+
+ return {
+ "success": True,
+ "access_token": tokens.get("access_token", ""),
+ "refresh_token": tokens.get("refresh_token", ""),
+ "id_token": tokens.get("id_token", ""),
+ "account_id": "v2_acct_" + chatgpt_client.device_id[:8],
+ "workspace_id": workspace_id,
+ "session_token": session_cookie,
+ }
+
+ # 7. 手机号验证需求:按成功返回,但标记为待补全
+ if oauth_client and self._is_phone_required_error(oauth_client.last_error):
+ self._log("检测到手机号验证需求,按成功返回并标记待补全")
+ return {
+ "success": True,
+ "metadata": {
+ "phone_verification_required": True,
+ "token_pending": True,
+ "oauth_error": oauth_client.last_error,
+ },
+ }
+
+ last_error = str(getattr(oauth_client, "last_error", "") or "").strip() or "获取最终 OAuth Tokens 失败"
+ return {"success": False, "error_message": f"账号已创建成功,但 {last_error}"}
+
+ except Exception as attempt_error:
+ last_error = str(attempt_error)
+ if attempt < self.max_retries - 1 and self._should_retry(last_error):
+ self._log(f"本轮出现异常,准备整流程重试: {last_error}")
+ continue
+ return {"success": False, "error_message": last_error}
+
+ return {"success": False, "error_message": last_error or "注册失败"}
diff --git a/src/core/anyauto/sentinel_token.py b/src/core/anyauto/sentinel_token.py
new file mode 100644
index 00000000..bf4bd1c9
--- /dev/null
+++ b/src/core/anyauto/sentinel_token.py
@@ -0,0 +1,206 @@
+"""
+Sentinel Token 生成器模块
+基于对 sentinel.openai.com SDK 的逆向分析
+"""
+
+import json
+import time
+import uuid
+import random
+import base64
+import hashlib
+
+
+class SentinelTokenGenerator:
+ """
+ Sentinel Token 纯 Python 生成器
+
+ 通过逆向 sentinel SDK 的 PoW 算法,纯 Python 构造合法的 openai-sentinel-token。
+ """
+
+ MAX_ATTEMPTS = 500000 # 最大 PoW 尝试次数
+ ERROR_PREFIX = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" # SDK 中的错误前缀常量
+
+ def __init__(self, device_id=None, user_agent=None):
+ self.device_id = device_id or str(uuid.uuid4())
+ self.user_agent = user_agent or (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/145.0.0.0 Safari/537.36"
+ )
+ self.requirements_seed = str(random.random())
+ self.sid = str(uuid.uuid4())
+
+ @staticmethod
+ def _fnv1a_32(text):
+ """
+ FNV-1a 32位哈希算法(从 SDK JS 逆向还原)
+ """
+ h = 2166136261 # FNV offset basis
+ for ch in text:
+ code = ord(ch)
+ h ^= code
+ h = (h * 16777619) & 0xFFFFFFFF
+
+ # xorshift 混合(murmurhash3 finalizer)
+ h ^= h >> 16
+ h = (h * 2246822507) & 0xFFFFFFFF
+ h ^= h >> 13
+ h = (h * 3266489909) & 0xFFFFFFFF
+ h ^= h >> 16
+ h = h & 0xFFFFFFFF
+
+ return format(h, "08x")
+
+ def _get_config(self):
+ """构造浏览器环境数据数组"""
+ from datetime import datetime, timezone
+
+ screen_info = "1920x1080"
+ now = datetime.now(timezone.utc)
+ date_str = now.strftime("%a %b %d %Y %H:%M:%S GMT+0000 (Coordinated Universal Time)")
+ js_heap_limit = 4294705152
+ nav_random1 = random.random()
+ ua = self.user_agent
+ script_src = "https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js"
+ script_version = None
+ data_build = None
+ language = "en-US"
+ languages = "en-US,en"
+ nav_random2 = random.random()
+
+ nav_props = [
+ "vendorSub", "productSub", "vendor", "maxTouchPoints",
+ "scheduling", "userActivation", "doNotTrack", "geolocation",
+ "connection", "plugins", "mimeTypes", "pdfViewerEnabled",
+ "webkitTemporaryStorage", "webkitPersistentStorage",
+ "hardwareConcurrency", "cookieEnabled", "credentials",
+ "mediaDevices", "permissions", "locks", "ink",
+ ]
+ nav_prop = random.choice(nav_props)
+ nav_val = f"{nav_prop}−undefined"
+
+ doc_key = random.choice(["location", "implementation", "URL", "documentURI", "compatMode"])
+ win_key = random.choice(["Object", "Function", "Array", "Number", "parseFloat", "undefined"])
+ perf_now = random.uniform(1000, 50000)
+ hardware_concurrency = random.choice([4, 8, 12, 16])
+ time_origin = time.time() * 1000 - perf_now
+
+ config = [
+ screen_info, date_str, js_heap_limit, nav_random1, ua,
+ script_src, script_version, data_build, language, languages,
+ nav_random2, nav_val, doc_key, win_key, perf_now,
+ self.sid, "", hardware_concurrency, time_origin,
+ ]
+ return config
+
+ @staticmethod
+ def _base64_encode(data):
+ """模拟 SDK 的 E() 函数:JSON.stringify → TextEncoder.encode → btoa"""
+ json_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
+ encoded = json_str.encode("utf-8")
+ return base64.b64encode(encoded).decode("ascii")
+
+ def _run_check(self, start_time, seed, difficulty, config, nonce):
+ """单次 PoW 检查"""
+ config[3] = nonce
+ config[9] = round((time.time() - start_time) * 1000)
+ data = self._base64_encode(config)
+ hash_input = seed + data
+ hash_hex = self._fnv1a_32(hash_input)
+ diff_len = len(difficulty)
+ if hash_hex[:diff_len] <= difficulty:
+ return data + "~S"
+ return None
+
+ def generate_token(self, seed=None, difficulty=None):
+ """生成 sentinel token(完整 PoW 流程)"""
+ if seed is None:
+ seed = self.requirements_seed
+ difficulty = difficulty or "0"
+
+ start_time = time.time()
+ config = self._get_config()
+
+ for i in range(self.MAX_ATTEMPTS):
+ result = self._run_check(start_time, seed, difficulty, config, i)
+ if result:
+ return "gAAAAAB" + result
+
+ return "gAAAAAB" + self.ERROR_PREFIX + self._base64_encode(str(None))
+
+ def generate_requirements_token(self):
+ """生成 requirements token(不需要服务端参数)"""
+ config = self._get_config()
+ config[3] = 1
+ config[9] = round(random.uniform(5, 50))
+ data = self._base64_encode(config)
+ return "gAAAAAC" + data
+
+
+def fetch_sentinel_challenge(session, device_id, flow="authorize_continue", user_agent=None, sec_ch_ua=None, impersonate=None):
+ """调用 sentinel 后端 API 获取 challenge 数据"""
+ generator = SentinelTokenGenerator(device_id=device_id, user_agent=user_agent)
+ req_body = {
+ "p": generator.generate_requirements_token(),
+ "id": device_id,
+ "flow": flow,
+ }
+
+ headers = {
+ "Content-Type": "text/plain;charset=UTF-8",
+ "Referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html",
+ "Origin": "https://sentinel.openai.com",
+ "User-Agent": user_agent or "Mozilla/5.0",
+ "sec-ch-ua": sec_ch_ua or '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": '"Windows"',
+ }
+
+ kwargs = {
+ "data": json.dumps(req_body),
+ "headers": headers,
+ "timeout": 20,
+ }
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+
+ try:
+ resp = session.post("https://sentinel.openai.com/backend-api/sentinel/req", **kwargs)
+ if resp.status_code == 200:
+ return resp.json()
+ except Exception:
+ pass
+
+ return None
+
+
+def build_sentinel_token(session, device_id, flow="authorize_continue", user_agent=None, sec_ch_ua=None, impersonate=None):
+ """构建完整的 openai-sentinel-token JSON 字符串"""
+ challenge = fetch_sentinel_challenge(session, device_id, flow=flow, user_agent=user_agent, sec_ch_ua=sec_ch_ua, impersonate=impersonate)
+
+ if not challenge:
+ return None
+
+ c_value = challenge.get("token", "")
+ if not c_value:
+ return None
+
+ pow_data = challenge.get("proofofwork") or {}
+ generator = SentinelTokenGenerator(device_id=device_id, user_agent=user_agent)
+
+ if pow_data.get("required") and pow_data.get("seed"):
+ p_value = generator.generate_token(
+ seed=pow_data.get("seed"),
+ difficulty=pow_data.get("difficulty", "0"),
+ )
+ else:
+ p_value = generator.generate_requirements_token()
+
+ return json.dumps({
+ "p": p_value,
+ "t": "",
+ "c": c_value,
+ "id": device_id,
+ "flow": flow,
+ }, separators=(",", ":"))
diff --git a/src/core/anyauto/utils.py b/src/core/anyauto/utils.py
new file mode 100644
index 00000000..7d2a4a39
--- /dev/null
+++ b/src/core/anyauto/utils.py
@@ -0,0 +1,362 @@
+"""
+通用工具函数模块
+"""
+
+from dataclasses import dataclass, field
+import random
+import string
+import secrets
+import hashlib
+import base64
+import uuid
+import re
+from urllib.parse import urlparse
+from typing import Any, Dict
+
+
+@dataclass
+class FlowState:
+ """OpenAI Auth/Registration 流程中的页面状态。"""
+
+ page_type: str = ""
+ continue_url: str = ""
+ method: str = "GET"
+ current_url: str = ""
+ source: str = ""
+ payload: Dict[str, Any] = field(default_factory=dict)
+ raw: Dict[str, Any] = field(default_factory=dict)
+
+
+def generate_device_id():
+ """生成设备唯一标识(oai-did),UUID v4 格式"""
+ return str(uuid.uuid4())
+
+
+def generate_random_password(length=16):
+ """生成符合 OpenAI 要求的随机密码"""
+ chars = string.ascii_letters + string.digits + "!@#$%"
+ pwd = list(
+ random.choice(string.ascii_uppercase)
+ + random.choice(string.ascii_lowercase)
+ + random.choice(string.digits)
+ + random.choice("!@#$%")
+ + "".join(random.choice(chars) for _ in range(length - 4))
+ )
+ random.shuffle(pwd)
+ return "".join(pwd)
+
+
+def generate_random_name():
+ """随机生成自然的英文姓名,返回 (first_name, last_name)"""
+ first = [
+ "James", "Robert", "John", "Michael", "David", "William", "Richard",
+ "Mary", "Jennifer", "Linda", "Elizabeth", "Susan", "Jessica", "Sarah",
+ "Emily", "Emma", "Olivia", "Sophia", "Liam", "Noah", "Oliver", "Ethan",
+ ]
+ last = [
+ "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller",
+ "Davis", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Martin",
+ ]
+ return random.choice(first), random.choice(last)
+
+
+def generate_random_birthday():
+ """生成随机生日字符串,格式 YYYY-MM-DD(20~30岁)"""
+ year = random.randint(1996, 2006)
+ month = random.randint(1, 12)
+ day = random.randint(1, 28)
+ return f"{year:04d}-{month:02d}-{day:02d}"
+
+
+def generate_datadog_trace():
+ """生成 Datadog APM 追踪头"""
+ trace_id = str(random.getrandbits(64))
+ parent_id = str(random.getrandbits(64))
+ trace_hex = format(int(trace_id), "016x")
+ parent_hex = format(int(parent_id), "016x")
+ return {
+ "traceparent": f"00-0000000000000000{trace_hex}-{parent_hex}-01",
+ "tracestate": "dd=s:1;o:rum",
+ "x-datadog-origin": "rum",
+ "x-datadog-parent-id": parent_id,
+ "x-datadog-sampling-priority": "1",
+ "x-datadog-trace-id": trace_id,
+ }
+
+
+def generate_pkce():
+ """生成 PKCE code_verifier 和 code_challenge"""
+ code_verifier = (
+ base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode("ascii")
+ )
+ digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
+ code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
+ return code_verifier, code_challenge
+
+
+def decode_jwt_payload(token):
+ """解析 JWT token 的 payload 部分"""
+ try:
+ parts = token.split(".")
+ if len(parts) != 3:
+ return {}
+ payload = parts[1]
+ padding = 4 - len(payload) % 4
+ if padding != 4:
+ payload += "=" * padding
+ decoded = base64.urlsafe_b64decode(payload)
+ import json
+ return json.loads(decoded)
+ except Exception:
+ return {}
+
+
+def extract_code_from_url(url):
+ """从 URL 中提取 authorization code"""
+ if not url or "code=" not in url:
+ return None
+ try:
+ from urllib.parse import urlparse, parse_qs
+ return parse_qs(urlparse(url).query).get("code", [None])[0]
+ except Exception:
+ return None
+
+
+def normalize_page_type(value):
+ """将 page.type 归一化为便于分支判断的 snake_case。"""
+ return str(value or "").strip().lower().replace("-", "_").replace("/", "_").replace(" ", "_")
+
+
+def normalize_flow_url(url, auth_base="https://auth.openai.com"):
+ """将 continue_url / payload.url 归一化成绝对 URL。"""
+ value = str(url or "").strip()
+ if not value:
+ return ""
+ if value.startswith("//"):
+ return f"https:{value}"
+ if value.startswith("/"):
+ return f"{auth_base.rstrip('/')}{value}"
+ return value
+
+
+def infer_page_type_from_url(url):
+ """从 URL 推断流程状态,用于服务端未返回 page.type 时兜底。"""
+ if not url:
+ return ""
+
+ try:
+ parsed = urlparse(url)
+ except Exception:
+ return ""
+
+ host = (parsed.netloc or "").lower()
+ path = (parsed.path or "").lower()
+
+ if "code=" in (parsed.query or ""):
+ return "oauth_callback"
+ if "chatgpt.com" in host and "/api/auth/callback/" in path:
+ return "callback"
+ if "create-account/password" in path:
+ return "create_account_password"
+ if "email-verification" in path or "email-otp" in path:
+ return "email_otp_verification"
+ if "about-you" in path:
+ return "about_you"
+ if "log-in/password" in path:
+ return "login_password"
+ if "sign-in-with-chatgpt" in path and "consent" in path:
+ return "consent"
+ if "workspace" in path and "select" in path:
+ return "workspace_selection"
+ if "organization" in path and "select" in path:
+ return "organization_selection"
+ if "add-phone" in path:
+ return "add_phone"
+ if "callback" in path:
+ return "callback"
+ if "chatgpt.com" in host and path in {"", "/"}:
+ return "chatgpt_home"
+ if path:
+ return normalize_page_type(path.strip("/").replace("/", "_"))
+ return ""
+
+
+def extract_flow_state(data=None, current_url="", auth_base="https://auth.openai.com", default_method="GET"):
+ """从 API 响应或 URL 中提取统一的流程状态。"""
+ raw = data if isinstance(data, dict) else {}
+ page = raw.get("page") or {}
+ payload = page.get("payload") or {}
+
+ continue_url = normalize_flow_url(
+ raw.get("continue_url") or payload.get("url") or "",
+ auth_base=auth_base,
+ )
+ effective_current_url = continue_url if raw and continue_url else current_url
+ current = normalize_flow_url(effective_current_url or continue_url, auth_base=auth_base)
+ page_type = normalize_page_type(page.get("type")) or infer_page_type_from_url(continue_url or current)
+ method = str(raw.get("method") or payload.get("method") or default_method or "GET").upper()
+
+ return FlowState(
+ page_type=page_type,
+ continue_url=continue_url,
+ method=method,
+ current_url=current,
+ source="api" if raw else "url",
+ payload=payload if isinstance(payload, dict) else {},
+ raw=raw,
+ )
+
+
+def describe_flow_state(state: FlowState):
+ """生成简短的流程状态描述,便于记录日志。"""
+ target = state.continue_url or state.current_url or "-"
+ return f"page={state.page_type or '-'} method={state.method or '-'} next={target[:80]}..."
+
+
+def random_delay(low=0.3, high=1.0):
+ """随机延迟"""
+ import time
+ time.sleep(random.uniform(low, high))
+
+
+def extract_chrome_full_version(user_agent):
+ """从 UA 中提取完整的 Chrome 版本号。"""
+ if not user_agent:
+ return ""
+ match = re.search(r"Chrome/([0-9.]+)", user_agent)
+ return match.group(1) if match else ""
+
+
+def _registrable_domain(hostname):
+ """粗略提取可注册域名,用于推断 Sec-Fetch-Site。"""
+ if not hostname:
+ return ""
+ host = hostname.split(":")[0].strip(".").lower()
+ parts = [part for part in host.split(".") if part]
+ if len(parts) <= 2:
+ return ".".join(parts)
+ return ".".join(parts[-2:])
+
+
+def infer_sec_fetch_site(url, referer=None, navigation=False):
+ """根据目标 URL 和 Referer 推断 Sec-Fetch-Site。"""
+ if not referer:
+ return "none" if navigation else "same-origin"
+
+ try:
+ target = urlparse(url or "")
+ source = urlparse(referer or "")
+
+ if not target.scheme or not target.netloc or not source.netloc:
+ return "none" if navigation else "same-origin"
+
+ if (target.scheme, target.netloc) == (source.scheme, source.netloc):
+ return "same-origin"
+
+ if _registrable_domain(target.hostname) == _registrable_domain(source.hostname):
+ return "same-site"
+ except Exception:
+ pass
+
+ return "cross-site"
+
+
+def build_sec_ch_ua_full_version_list(sec_ch_ua, chrome_full_version):
+ """根据 sec-ch-ua 生成 sec-ch-ua-full-version-list。"""
+ if not sec_ch_ua or not chrome_full_version:
+ return ""
+
+ entries = []
+ for brand, version in re.findall(r'"([^"]+)";v="([^"]+)"', sec_ch_ua):
+ full_version = chrome_full_version if brand in {"Chromium", "Google Chrome"} else f"{version}.0.0.0"
+ entries.append(f'"{brand}";v="{full_version}"')
+
+ return ", ".join(entries)
+
+
+def build_browser_headers(
+ *,
+ url,
+ user_agent,
+ sec_ch_ua=None,
+ chrome_full_version=None,
+ accept=None,
+ accept_language="en-US,en;q=0.9",
+ referer=None,
+ origin=None,
+ content_type=None,
+ navigation=False,
+ fetch_mode=None,
+ fetch_dest=None,
+ fetch_site=None,
+ headed=False,
+ extra_headers=None,
+):
+ """构造更接近真实 Chrome 有头浏览器的请求头。"""
+ chrome_full = chrome_full_version or extract_chrome_full_version(user_agent)
+ full_version_list = build_sec_ch_ua_full_version_list(sec_ch_ua, chrome_full)
+
+ headers = {
+ "User-Agent": user_agent or "Mozilla/5.0",
+ "Accept-Language": accept_language,
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": '"Windows"',
+ "sec-ch-ua-arch": '"x86"',
+ "sec-ch-ua-bitness": '"64"',
+ }
+
+ if accept:
+ headers["Accept"] = accept
+ if referer:
+ headers["Referer"] = referer
+ if origin:
+ headers["Origin"] = origin
+ if content_type:
+ headers["Content-Type"] = content_type
+ if sec_ch_ua:
+ headers["sec-ch-ua"] = sec_ch_ua
+ if chrome_full:
+ headers["sec-ch-ua-full-version"] = f'"{chrome_full}"'
+ headers["sec-ch-ua-platform-version"] = '"15.0.0"'
+ if full_version_list:
+ headers["sec-ch-ua-full-version-list"] = full_version_list
+
+ if navigation:
+ headers["Sec-Fetch-Dest"] = "document"
+ headers["Sec-Fetch-Mode"] = "navigate"
+ headers["Sec-Fetch-User"] = "?1"
+ headers["Upgrade-Insecure-Requests"] = "1"
+ headers["Cache-Control"] = "max-age=0"
+ else:
+ headers["Sec-Fetch-Dest"] = fetch_dest or "empty"
+ headers["Sec-Fetch-Mode"] = fetch_mode or "cors"
+
+ headers["Sec-Fetch-Site"] = fetch_site or infer_sec_fetch_site(url, referer, navigation=navigation)
+
+ if headed:
+ headers.setdefault("Priority", "u=0, i" if navigation else "u=1, i")
+ headers.setdefault("DNT", "1")
+ headers.setdefault("Sec-GPC", "1")
+
+ if extra_headers:
+ for key, value in extra_headers.items():
+ if value is not None:
+ headers[key] = value
+
+ return headers
+
+
+def seed_oai_device_cookie(session, device_id):
+ """在 ChatGPT / OpenAI 相关域上同步设置 oai-did。"""
+ for domain in (
+ "chatgpt.com",
+ ".chatgpt.com",
+ "openai.com",
+ ".openai.com",
+ "auth.openai.com",
+ ".auth.openai.com",
+ ):
+ try:
+ session.cookies.set("oai-did", device_id, domain=domain)
+ except Exception:
+ continue
diff --git a/src/core/register.py b/src/core/register.py
index 92d8af86..bd13791e 100644
--- a/src/core/register.py
+++ b/src/core/register.py
@@ -19,6 +19,7 @@
from .openai.oauth import OAuthManager, OAuthStart
from .http_client import OpenAIHTTPClient, HTTPClientError
+from .anyauto.register_flow import AnyAutoRegistrationEngine
from ..services import EmailServiceFactory, BaseEmailService, EmailServiceType
from ..database import crud
from ..database.session import get_db
@@ -2901,176 +2902,59 @@ def _handle_oauth_callback(self, callback_url: str) -> Optional[Dict[str, Any]]:
def run(self) -> RegistrationResult:
"""
- 执行完整的注册流程
-
- 支持已注册账号自动登录:
- - 如果检测到邮箱已注册,自动切换到登录流程
- - 已注册账号跳过:设置密码、发送验证码、创建用户账户
- - 共用步骤:获取验证码、验证验证码、Workspace 和 OAuth 回调
-
- Returns:
- RegistrationResult: 注册结果
+ 执行 any-auto-register 风格的注册流程
"""
result = RegistrationResult(success=False, logs=self.logs)
-
try:
- token_ready = False
- self._is_existing_account = False
- self._token_acquisition_requires_login = False
- self._otp_sent_at = None
- self._create_account_continue_url = None
- self._create_account_workspace_id = None
- self._create_account_account_id = None
- self._create_account_refresh_token = None
- self._create_account_callback_url = None
- self._create_account_page_type = None
- self._last_validate_otp_continue_url = None
- self._last_validate_otp_workspace_id = None
-
- self._log("=" * 60)
- self._log("注册流程启动,开始替你敲门")
- self._log("=" * 60)
- self._log(f"注册入口链路配置: {self.registration_entry_flow}")
- configured_entry_flow = self.registration_entry_flow
- service_type_raw = getattr(self.email_service, "service_type", "")
- service_type_value = str(getattr(service_type_raw, "value", service_type_raw) or "").strip().lower()
- effective_entry_flow = configured_entry_flow
- if service_type_value == "outlook":
- self._log("检测到 Outlook 邮箱,自动使用 Outlook 入口链路(无需在设置中选择)")
- effective_entry_flow = "outlook"
-
- # 1. 检查 IP 地理位置
- self._log("1. 先看看这条网络从哪儿来,别一开局就站错片场...")
- ip_ok, location = self._check_ip_location()
- if not ip_ok:
- result.error_message = f"IP 地理位置不支持: {location}"
- self._log(f"IP 检查失败: {location}", "error")
- return result
-
- self._log(f"IP 位置: {location}")
+ settings = get_settings()
+ max_retries = int(getattr(settings, "registration_max_retries", 3) or 3)
+
+ flow_engine = AnyAutoRegistrationEngine(
+ email_service=self.email_service,
+ proxy_url=self.proxy_url,
+ callback_logger=self._log,
+ max_retries=max_retries,
+ browser_mode="protocol",
+ extra_config=None,
+ )
- # 2. 创建邮箱
- self._log("2. 开个新邮箱,准备收信...")
- if not self._create_email():
- result.error_message = "创建邮箱失败"
- return result
+ flow_result = flow_engine.run()
- result.email = self.email
+ # 同步关键状态供后续存库/导出
+ self.email_info = flow_engine.email_info
+ self.email = flow_engine.email
+ self.inbox_email = flow_engine.inbox_email
+ self.password = flow_engine.password
+ self.session = flow_engine.session
+ self.device_id = flow_engine.device_id
- # 3. 准备首轮授权流程
- did, sen_token = self._prepare_authorize_flow("首次授权")
- if not did:
- result.error_message = "获取 Device ID 失败"
- return result
- result.device_id = did
- if not sen_token:
- result.error_message = "Sentinel POW 验证失败"
- return result
+ result.email = self.email or ""
+ result.password = self.password or ""
+ result.device_id = self.device_id or ""
- # 4. 提交注册入口邮箱
- self._log("4. 递上邮箱,看看 OpenAI 这球怎么接...")
- signup_result = self._submit_signup_form(did, sen_token)
- if not signup_result.success:
- result.error_message = f"提交注册表单失败: {signup_result.error_message}"
+ if not flow_result or not flow_result.get("success"):
+ result.error_message = (flow_result or {}).get("error_message", "注册失败")
return result
- if self._is_existing_account:
- self._log("检测到这是老朋友账号,直接切去登录拿 token,不走弯路")
- else:
- self._log("5. 设置密码,别让小偷偷笑...")
- password_ok, _ = self._register_password(did, sen_token)
- if not password_ok:
- result.error_message = self._last_register_password_error or "注册密码失败"
- return result
-
- self._log("6. 催一下注册验证码出门,邮差该冲刺了...")
- if not self._send_verification_code():
- result.error_message = "发送验证码失败"
- return result
-
- self._log("7. 等验证码飞来,邮箱请注意查收...")
- self._log("8. 对一下验证码,看看是不是本人...")
- if not self._verify_email_otp_with_retry(stage_label="注册验证码", max_attempts=3):
- result.error_message = "验证验证码失败"
- return result
-
- self._log("9. 给账号办个正式户口,名字写档案里...")
- if not self._create_user_account():
- result.error_message = "创建用户账户失败"
- return result
-
- if self._create_account_callback_url:
- self._log("create_account 已返回 OAuth callback,尝试跳过二次登录...")
- token_ready = self._consume_create_account_callback(result)
- if token_ready:
- self._log("create_account callback 已完成会话交换,跳过重登流程")
- else:
- result.error_message = "create_account callback 获取 access_token 失败"
- self._log("create_account callback 未能完成会话,终止本轮注册", "error")
- return result
- if (not token_ready) and effective_entry_flow in {"native", "outlook"}:
- login_ready, login_error = self._restart_login_flow()
- if not login_ready:
- result.error_message = login_error
- return result
- if effective_entry_flow == "outlook":
- self._log("注册入口链路: Outlook(迁移版,按朋友版 Outlook 主流程收尾)")
- else:
- self._log("注册入口链路: ABCard(新账号不重登,直接抓取会话)")
-
- if not token_ready:
- if effective_entry_flow == "native":
- if not self._complete_token_exchange_native_backup(result):
- return result
- elif effective_entry_flow == "outlook":
- if not self._complete_token_exchange_outlook(result):
- return result
- else:
- use_abcard_entry = (effective_entry_flow == "abcard") and (not self._is_existing_account)
- if not self._complete_token_exchange(result, require_login_otp=not use_abcard_entry):
- return result
- else:
- # 已通过 create_account callback 拿到 token,补齐必要字段
- if not result.account_id and self._create_account_account_id:
- result.account_id = str(self._create_account_account_id or "").strip()
- if not result.workspace_id and self._create_account_workspace_id:
- result.workspace_id = str(self._create_account_workspace_id or "").strip()
- if not result.refresh_token and self._create_account_refresh_token:
- result.refresh_token = str(self._create_account_refresh_token or "").strip()
- if not result.device_id:
- result.device_id = str(self.device_id or "")
-
- # 10. 完成
- self._log("=" * 60)
- if self._is_existing_account:
- self._log("登录成功,老朋友顺利回家")
- else:
- self._log("注册成功,账号已经稳稳落地,可以开香槟了")
- self._log(f"邮箱: {result.email}")
- self._log(f"Device ID: {result.device_id or '-'}")
- self._log(f"Account ID: {result.account_id}")
- self._log(f"Workspace ID: {result.workspace_id}")
- self._log("=" * 60)
-
result.success = True
- settings = get_settings()
- client_id = str(getattr(settings, "openai_client_id", "") or getattr(self.oauth_manager, "client_id", "") or "").strip()
- result.metadata = {
+ result.access_token = flow_result.get("access_token", "")
+ result.refresh_token = flow_result.get("refresh_token", "")
+ result.id_token = flow_result.get("id_token", "")
+ result.session_token = flow_result.get("session_token", "")
+ result.account_id = flow_result.get("account_id", "") or ""
+ result.workspace_id = flow_result.get("workspace_id", "") or ""
+ result.source = "register"
+
+ metadata = flow_result.get("metadata") or {}
+ metadata.update({
"email_service": self.email_service.service_type.value,
"proxy_used": self.proxy_url,
"registered_at": datetime.now().isoformat(),
- "is_existing_account": self._is_existing_account,
- "token_acquired_via_relogin": self._token_acquisition_requires_login,
- "client_id": client_id,
- "device_id": result.device_id,
+ "registration_flow": "any-auto-register",
"has_session_token": bool(result.session_token),
"has_access_token": bool(result.access_token),
- "has_refresh_token": bool(result.refresh_token),
- "registration_entry_flow": configured_entry_flow,
- "registration_entry_flow_effective": effective_entry_flow,
- # 对齐 K:\1\2:原生入口允许无 session_token 成功,但会标记待补。
- "session_token_pending": (effective_entry_flow == "native") and (not bool(result.session_token)),
- }
+ })
+ result.metadata = metadata
return result
@@ -3078,7 +2962,6 @@ def run(self) -> RegistrationResult:
self._log(f"注册过程中发生未预期错误: {e}", "error")
result.error_message = str(e)
return result
-
def save_to_database(self, result: RegistrationResult) -> bool:
"""
保存注册结果到数据库
From ccb7d30e2c0be389bf215488b45290927b812351 Mon Sep 17 00:00:00 2001
From: cong <3135055939@qq.com>
Date: Tue, 31 Mar 2026 16:22:36 +0800
Subject: [PATCH 5/8] Retry wrong email OTP codes in V2 flow
---
src/core/anyauto/chatgpt_client.py | 46 ++++++++++++++++++++++++------
src/core/anyauto/register_flow.py | 4 ++-
2 files changed, 41 insertions(+), 9 deletions(-)
diff --git a/src/core/anyauto/chatgpt_client.py b/src/core/anyauto/chatgpt_client.py
index 0f4cb825..63d03ab5 100644
--- a/src/core/anyauto/chatgpt_client.py
+++ b/src/core/anyauto/chatgpt_client.py
@@ -646,9 +646,12 @@ def verify_email_otp(self, otp_code, return_state=False):
self._log(f"验证成功 {describe_flow_state(next_state)}")
return (True, next_state) if return_state else (True, "验证成功")
else:
- error_msg = r.text[:200]
+ try:
+ error_msg = r.text[:200]
+ except Exception:
+ error_msg = ""
self._log(f"验证失败: {r.status_code} - {error_msg}")
- return False, f"HTTP {r.status_code}"
+ return False, f"HTTP {r.status_code}: {error_msg}".strip()
except Exception as e:
self._log(f"验证异常: {e}")
@@ -827,12 +830,39 @@ def register_complete_flow(self, email, password, first_name, last_name, birthda
if not otp_code:
return False, "未收到验证码"
- success, next_state = self.verify_email_otp(otp_code, return_state=True)
- if not success:
- return False, f"验证码失败: {next_state}"
- otp_verified = True
- state = next_state
- self.last_registration_state = state
+ tried_codes = {otp_code}
+ for _ in range(3):
+ success, next_state = self.verify_email_otp(otp_code, return_state=True)
+ if success:
+ otp_verified = True
+ state = next_state
+ self.last_registration_state = state
+ break
+
+ err_text = str(next_state or "")
+ is_wrong_code = any(
+ marker in err_text.lower()
+ for marker in (
+ "wrong_email_otp_code",
+ "wrong code",
+ "http 401",
+ )
+ )
+ if not is_wrong_code:
+ return False, f"验证码失败: {next_state}"
+
+ self._log("验证码疑似过期/错误,尝试获取新验证码...")
+ otp_code = skymail_client.wait_for_verification_code(
+ email,
+ timeout=45,
+ exclude_codes=tried_codes,
+ )
+ if not otp_code:
+ return False, "未收到新的验证码"
+ tried_codes.add(otp_code)
+
+ if not otp_verified:
+ return False, "验证码失败: 多次尝试仍无效"
continue
if self._state_is_about_you(state):
diff --git a/src/core/anyauto/register_flow.py b/src/core/anyauto/register_flow.py
index 78196f78..e717b74d 100644
--- a/src/core/anyauto/register_flow.py
+++ b/src/core/anyauto/register_flow.py
@@ -31,13 +31,15 @@ def wait_for_verification_code(self, email, timeout=60, otp_sent_at=None, exclud
exclude = set(exclude_codes or [])
exclude.update(self._used_codes)
deadline = time.time() + max(1, int(timeout))
+ sent_at = otp_sent_at or time.time()
+
while time.time() < deadline:
remaining = max(1, int(deadline - time.time()))
code = self.es.get_verification_code(
email=email,
email_id=self.email_id,
timeout=remaining,
- otp_sent_at=otp_sent_at,
+ otp_sent_at=sent_at,
)
if not code:
return None
From efa31601252457e1bdeb7d590e38e802ee14857f Mon Sep 17 00:00:00 2001
From: cong <3135055939@qq.com>
Date: Tue, 31 Mar 2026 18:58:11 +0800
Subject: [PATCH 6/8] Fill ChatGPT account id from tokens
---
src/core/anyauto/register_flow.py | 29 ++++++++++++++++++++++++-----
src/core/register.py | 28 ++++++++++++++++++++++++++++
2 files changed, 52 insertions(+), 5 deletions(-)
diff --git a/src/core/anyauto/register_flow.py b/src/core/anyauto/register_flow.py
index e717b74d..7872c9ae 100644
--- a/src/core/anyauto/register_flow.py
+++ b/src/core/anyauto/register_flow.py
@@ -12,7 +12,7 @@
from .chatgpt_client import ChatGPTClient
from .oauth_client import OAuthClient
-from .utils import generate_random_name, generate_random_birthday
+from .utils import generate_random_name, generate_random_birthday, decode_jwt_payload
from ...config.constants import PASSWORD_CHARSET, DEFAULT_PASSWORD_LENGTH
from ...config.settings import get_settings
@@ -109,6 +109,18 @@ def _should_retry(message: str) -> bool:
]
return any(marker.lower() in text for marker in retriable_markers)
+ @staticmethod
+ def _extract_account_id_from_token(token: str) -> str:
+ payload = decode_jwt_payload(token)
+ if not isinstance(payload, dict):
+ return ""
+ auth_claims = payload.get("https://api.openai.com/auth") or {}
+ for key in ("chatgpt_account_id", "account_id", "workspace_id"):
+ value = str(auth_claims.get(key) or payload.get(key) or "").strip()
+ if value:
+ return value
+ return ""
+
@staticmethod
def _is_phone_required_error(message: str) -> bool:
text = str(message or "").lower()
@@ -209,12 +221,18 @@ def run(self):
session_ok, session_result = chatgpt_client.reuse_session_and_get_tokens()
if session_ok:
self._log("Token 提取完成!")
+ account_id = str(session_result.get("account_id", "") or "").strip()
+ if not account_id:
+ account_id = str(session_result.get("workspace_id", "") or "").strip()
+ if not account_id:
+ account_id = self._extract_account_id_from_token(session_result.get("access_token", ""))
+ workspace_id = str(session_result.get("workspace_id", "") or "").strip() or account_id
return {
"success": True,
"access_token": session_result.get("access_token", ""),
"session_token": session_result.get("session_token", ""),
- "account_id": session_result.get("account_id", "") or session_result.get("user_id", ""),
- "workspace_id": session_result.get("workspace_id", ""),
+ "account_id": account_id,
+ "workspace_id": workspace_id,
"metadata": {
"auth_provider": session_result.get("auth_provider", ""),
"expires": session_result.get("expires", ""),
@@ -280,13 +298,14 @@ def run(self):
except Exception:
pass
+ account_id = self._extract_account_id_from_token(tokens.get("access_token", "")) or workspace_id
return {
"success": True,
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token", ""),
"id_token": tokens.get("id_token", ""),
- "account_id": "v2_acct_" + chatgpt_client.device_id[:8],
- "workspace_id": workspace_id,
+ "account_id": account_id or ("v2_acct_" + chatgpt_client.device_id[:8]),
+ "workspace_id": workspace_id or account_id,
"session_token": session_cookie,
}
diff --git a/src/core/register.py b/src/core/register.py
index bd13791e..2df48bd0 100644
--- a/src/core/register.py
+++ b/src/core/register.py
@@ -2945,6 +2945,34 @@ def run(self) -> RegistrationResult:
result.workspace_id = flow_result.get("workspace_id", "") or ""
result.source = "register"
+ def _extract_account_id_from_token(token: str) -> str:
+ try:
+ text = str(token or "").strip()
+ if "." not in text:
+ return ""
+ payload_part = text.split(".")[1]
+ pad = "=" * (-len(payload_part) % 4)
+ import base64, json as _json
+ decoded = base64.urlsafe_b64decode(payload_part + pad).decode("utf-8")
+ payload = _json.loads(decoded)
+ if not isinstance(payload, dict):
+ return ""
+ auth_claims = payload.get("https://api.openai.com/auth") or {}
+ for key in ("chatgpt_account_id", "account_id", "workspace_id"):
+ value = str(auth_claims.get(key) or payload.get(key) or "").strip()
+ if value:
+ return value
+ except Exception:
+ return ""
+ return ""
+
+ if not result.account_id:
+ result.account_id = _extract_account_id_from_token(result.access_token or result.id_token)
+ if not result.account_id and result.workspace_id:
+ result.account_id = result.workspace_id
+ if not result.workspace_id and result.account_id:
+ result.workspace_id = result.account_id
+
metadata = flow_result.get("metadata") or {}
metadata.update({
"email_service": self.email_service.service_type.value,
From 41bfd2faf512527c537b7b201a05156267b1aff7 Mon Sep 17 00:00:00 2001
From: cong <3135055939@qq.com>
Date: Tue, 31 Mar 2026 19:09:52 +0800
Subject: [PATCH 7/8] Add passwordless OTP OAuth recovery for add_phone
---
src/core/anyauto/chatgpt_client.py | 9 ++
src/core/anyauto/oauth_client.py | 192 +++++++++++++++++++++++++++++
src/core/anyauto/register_flow.py | 62 ++++++++++
3 files changed, 263 insertions(+)
diff --git a/src/core/anyauto/chatgpt_client.py b/src/core/anyauto/chatgpt_client.py
index 63d03ab5..13def6c0 100644
--- a/src/core/anyauto/chatgpt_client.py
+++ b/src/core/anyauto/chatgpt_client.py
@@ -228,6 +228,10 @@ def _state_is_about_you(self, state: FlowState):
target = f"{state.continue_url} {state.current_url}".lower()
return state.page_type == "about_you" or "about-you" in target
+ def _state_is_add_phone(self, state: FlowState):
+ target = f"{state.continue_url} {state.current_url}".lower()
+ return state.page_type == "add_phone" or "add-phone" in target
+
def _state_requires_navigation(self, state: FlowState):
if (state.method or "GET").upper() != "GET":
return False
@@ -881,6 +885,11 @@ def register_complete_flow(self, email, password, first_name, last_name, birthda
self.last_registration_state = state
continue
+ if self._state_is_add_phone(state):
+ self._log("检测到 add_phone 阶段,交由后续登录补全流程处理")
+ self.last_registration_state = state
+ return True, "add_phone_required"
+
if self._state_requires_navigation(state):
success, next_state = self._follow_flow_state(
state,
diff --git a/src/core/anyauto/oauth_client.py b/src/core/anyauto/oauth_client.py
index 494a5320..a891da9a 100644
--- a/src/core/anyauto/oauth_client.py
+++ b/src/core/anyauto/oauth_client.py
@@ -427,6 +427,7 @@ def _submit_authorize_continue(
impersonate=None,
authorize_url=None,
authorize_params=None,
+ screen_hint: str | None = None,
):
"""提交邮箱,获取 OAuth 流程的第一页状态。"""
self._log("步骤2: POST /api/accounts/authorize/continue")
@@ -460,6 +461,8 @@ def _submit_authorize_continue(
)
headers.update(generate_datadog_trace())
payload = {"username": {"kind": "email", "value": email}}
+ if screen_hint:
+ payload["screen_hint"] = screen_hint
try:
kwargs = {"json": payload, "headers": headers, "timeout": 30, "allow_redirects": False}
@@ -758,6 +761,167 @@ def login_and_get_tokens(self, email, password, device_id, user_agent=None, sec_
self._set_error("OAuth 状态机超出最大步数")
return None
+
+ def login_passwordless_and_get_tokens(self, email, device_id, user_agent=None, sec_ch_ua=None, impersonate=None, skymail_client=None):
+ """
+ 使用 passwordless 邮箱 OTP 完成 OAuth 登录流程,获取 tokens。
+ """
+ self.last_error = ""
+ self._log("开始 OAuth Passwordless 登录流程...")
+
+ if not skymail_client:
+ self._set_error("缺少接码客户端,无法执行 passwordless OTP")
+ return None
+
+ code_verifier, code_challenge = generate_pkce()
+ oauth_state = secrets.token_urlsafe(32)
+ authorize_params = {
+ "response_type": "code",
+ "client_id": self.oauth_client_id,
+ "redirect_uri": self.oauth_redirect_uri,
+ "scope": "openid profile email offline_access",
+ "code_challenge": code_challenge,
+ "code_challenge_method": "S256",
+ "state": oauth_state,
+ "prompt": "login",
+ "screen_hint": "login",
+ "login_hint": email,
+ }
+ authorize_url = f"{self.oauth_issuer}/oauth/authorize"
+
+ seed_oai_device_cookie(self.session, device_id)
+
+ self._log("步骤1: Bootstrap OAuth session (passwordless)...")
+ authorize_final_url = self._bootstrap_oauth_session(
+ authorize_url,
+ authorize_params,
+ device_id=device_id,
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ impersonate=impersonate,
+ )
+ if not authorize_final_url:
+ self._set_error("Bootstrap 失败")
+ return None
+
+ continue_referer = (
+ authorize_final_url
+ if authorize_final_url.startswith(self.oauth_issuer)
+ else f"{self.oauth_issuer}/log-in"
+ )
+
+ state = self._submit_authorize_continue(
+ email,
+ device_id,
+ continue_referer,
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ impersonate=impersonate,
+ authorize_url=authorize_url,
+ authorize_params=authorize_params,
+ screen_hint="login",
+ )
+ if not state:
+ if not self.last_error:
+ self._set_error("提交邮箱后未进入有效的 OAuth 状态")
+ return None
+
+ self._log(f"Passwordless OAuth 状态起点: {describe_flow_state(state)}")
+
+ send_ok, send_detail = self._send_email_otp(
+ device_id,
+ user_agent,
+ sec_ch_ua,
+ impersonate,
+ referer=state.current_url or continue_referer,
+ )
+ if not send_ok:
+ self._set_error(send_detail or "email-otp/send 失败")
+ return None
+
+ otp_state = self._state_from_url(f"{self.oauth_issuer}/email-verification")
+ next_state = self._handle_otp_verification(
+ email,
+ device_id,
+ user_agent,
+ sec_ch_ua,
+ impersonate,
+ skymail_client,
+ otp_state,
+ )
+ if not next_state:
+ if not self.last_error:
+ self._set_error("邮箱 OTP 验证后未进入下一步 OAuth 状态")
+ return None
+
+ state = next_state
+ referer = state.current_url or continue_referer
+
+ for step in range(20):
+ code = self._extract_code_from_state(state)
+ if code:
+ self._log(f"获取到 authorization code: {code[:20]}...")
+ self._log("步骤: POST /oauth/token")
+ tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate)
+ if tokens:
+ self._log("✅ Passwordless OAuth 登录成功")
+ else:
+ self._log("换取 tokens 失败")
+ return tokens
+
+ if self._state_is_add_phone(state):
+ self._set_error("add_phone_required")
+ return None
+
+ if self._state_requires_navigation(state):
+ code, next_state = self._follow_flow_state(
+ state,
+ referer=referer,
+ user_agent=user_agent,
+ impersonate=impersonate,
+ )
+ if code:
+ self._log(f"获取到 authorization code: {code[:20]}...")
+ self._log("步骤: POST /oauth/token")
+ tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate)
+ if tokens:
+ self._log("✅ Passwordless OAuth 登录成功")
+ else:
+ self._log("换取 tokens 失败")
+ return tokens
+ referer = state.current_url or referer
+ state = next_state
+ self._log(f"follow state -> {describe_flow_state(state)}")
+ continue
+
+ if self._state_supports_workspace_resolution(state):
+ self._log("执行 workspace/org 选择")
+ code, next_state = self._oauth_submit_workspace_and_org(
+ state.continue_url or state.current_url or f"{self.oauth_issuer}/sign-in-with-chatgpt/codex/consent",
+ device_id,
+ user_agent,
+ impersonate,
+ )
+ if code:
+ self._log(f"获取到 authorization code: {code[:20]}...")
+ self._log("步骤: POST /oauth/token")
+ tokens = self._exchange_code_for_tokens(code, code_verifier, user_agent, impersonate)
+ if tokens:
+ self._log("✅ Passwordless OAuth 登录成功")
+ else:
+ self._log("换取 tokens 失败")
+ return tokens
+ if next_state:
+ referer = state.current_url or referer
+ state = next_state
+ self._log(f"workspace state -> {describe_flow_state(state)}")
+ continue
+
+ self._set_error(f"未支持的 Passwordless OAuth 状态: {describe_flow_state(state)}")
+ return None
+
+ self._set_error("Passwordless OAuth 状态机超出最大步数")
+ return None
def _extract_code_from_url(self, url):
"""从 URL 中提取 code"""
@@ -1171,6 +1335,34 @@ def _exchange_code_for_tokens(self, code, code_verifier, user_agent, impersonate
return None
+ def _send_email_otp(self, device_id, user_agent, sec_ch_ua, impersonate, referer=None):
+ request_url = f"{self.oauth_issuer}/api/accounts/email-otp/send"
+ headers = self._headers(
+ request_url,
+ user_agent=user_agent,
+ sec_ch_ua=sec_ch_ua,
+ accept="application/json",
+ referer=referer or f"{self.oauth_issuer}/log-in",
+ origin=self.oauth_issuer,
+ fetch_site="same-origin",
+ extra_headers={"oai-device-id": device_id} if device_id else None,
+ )
+ headers.update(generate_datadog_trace())
+
+ try:
+ kwargs = {"headers": headers, "timeout": 30, "allow_redirects": False}
+ if impersonate:
+ kwargs["impersonate"] = impersonate
+ self._browser_pause(0.12, 0.25)
+ resp = self.session.get(request_url, **kwargs)
+ except Exception as e:
+ return False, f"email-otp/send 异常: {e}"
+
+ self._log(f"/email-otp/send -> {resp.status_code}")
+ if resp.status_code != 200:
+ return False, f"email-otp/send 失败: {resp.status_code} - {resp.text[:180]}"
+ return True, ""
+
def _send_phone_number(self, phone, device_id, user_agent, sec_ch_ua, impersonate):
request_url = f"{self.oauth_issuer}/api/accounts/add-phone/send"
headers = self._headers(
diff --git a/src/core/anyauto/register_flow.py b/src/core/anyauto/register_flow.py
index 7872c9ae..bf2171ae 100644
--- a/src/core/anyauto/register_flow.py
+++ b/src/core/anyauto/register_flow.py
@@ -136,6 +136,42 @@ def _is_phone_required_error(message: str) -> bool:
)
)
+ def _passwordless_oauth_reauth(
+ self,
+ chatgpt_client: ChatGPTClient,
+ email: str,
+ skymail_adapter: EmailServiceAdapter,
+ oauth_config: Dict[str, Any],
+ ) -> Optional[Dict[str, Any]]:
+ self._log("检测到 add_phone,尝试 passwordless OTP 登录补全 workspace...")
+ oauth_client = OAuthClient(
+ config=oauth_config,
+ proxy=self.proxy_url,
+ verbose=False,
+ browser_mode=self.browser_mode,
+ )
+ oauth_client._log = self._log
+
+ tokens = oauth_client.login_passwordless_and_get_tokens(
+ email,
+ chatgpt_client.device_id,
+ chatgpt_client.ua,
+ chatgpt_client.sec_ch_ua,
+ chatgpt_client.impersonate,
+ skymail_adapter,
+ )
+ if tokens and tokens.get("access_token"):
+ return {
+ "access_token": tokens.get("access_token", ""),
+ "refresh_token": tokens.get("refresh_token", ""),
+ "id_token": tokens.get("id_token", ""),
+ "session": oauth_client.session,
+ }
+
+ if oauth_client.last_error:
+ self._log(f"Passwordless OAuth 失败: {oauth_client.last_error}")
+ return None
+
def run(self):
"""
执行 any-auto-register 风格注册流程。
@@ -212,10 +248,36 @@ def run(self):
continue
return {"success": False, "error_message": last_error}
+ add_phone_required = "add_phone" in str(msg or "").lower()
+ try:
+ state = getattr(chatgpt_client, "last_registration_state", None)
+ if state:
+ target = f"{getattr(state, 'continue_url', '')} {getattr(state, 'current_url', '')}".lower()
+ if "add-phone" in target or "add_phone" in str(getattr(state, "page_type", "")).lower():
+ add_phone_required = True
+ except Exception:
+ pass
+
# 保存会话与设备
self.session = chatgpt_client.session
self.device_id = chatgpt_client.device_id
+ if add_phone_required:
+ pwdless = self._passwordless_oauth_reauth(
+ chatgpt_client,
+ normalized_email,
+ skymail_adapter,
+ oauth_config,
+ )
+ if pwdless and pwdless.get("access_token"):
+ self.session = pwdless.get("session") or self.session
+ return {
+ "success": True,
+ "access_token": pwdless.get("access_token", ""),
+ "refresh_token": pwdless.get("refresh_token", ""),
+ "id_token": pwdless.get("id_token", ""),
+ }
+
# 5. 复用 session 取 token
self._log("步骤 2/2: 优先复用注册会话提取 ChatGPT Session / AccessToken...")
session_ok, session_result = chatgpt_client.reuse_session_and_get_tokens()
From b6b726f893fa6c3aaaee0ba4b356a9f770b20800 Mon Sep 17 00:00:00 2001
From: w1196396546 <1196396546whq>
Date: Wed, 1 Apr 2026 09:35:39 +0800
Subject: [PATCH 8/8] Fix duplicate account handling in registration flow
---
src/core/register.py | 6 +-
src/database/crud.py | 98 +++++++++++++--
src/web/routes/registration.py | 9 +-
tests/test_account_crud.py | 153 ++++++++++++++++++++++++
tests/test_registration_task_binding.py | 99 +++++++++++++++
5 files changed, 352 insertions(+), 13 deletions(-)
create mode 100644 tests/test_account_crud.py
create mode 100644 tests/test_registration_task_binding.py
diff --git a/src/core/register.py b/src/core/register.py
index 2df48bd0..deb65850 100644
--- a/src/core/register.py
+++ b/src/core/register.py
@@ -2154,7 +2154,8 @@ def _mark_email_as_registered(self):
email_service=self.email_service.service_type.value,
email_service_id=self.email_info.get("service_id") if self.email_info else None,
status="failed",
- extra_data={"register_failed_reason": "email_already_registered_on_openai"}
+ extra_data={"register_failed_reason": "email_already_registered_on_openai"},
+ if_exists="return",
)
self._log(f"已在数据库中标记邮箱 {self.email} 为已注册状态")
except Exception as e:
@@ -3025,7 +3026,8 @@ def save_to_database(self, result: RegistrationResult) -> bool:
id_token=result.id_token,
proxy_used=self.proxy_url,
extra_data=result.metadata,
- source=result.source
+ source=result.source,
+ if_exists="merge",
)
self._log(f"账户已存进数据库,落袋为安,ID: {account.id}")
diff --git a/src/database/crud.py b/src/database/crud.py
index 3c6c8492..c5342875 100644
--- a/src/database/crud.py
+++ b/src/database/crud.py
@@ -2,10 +2,11 @@
数据库 CRUD 操作
"""
-from typing import List, Optional, Dict, Any, Union
+from typing import List, Optional, Dict, Any, Union, Literal
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, desc, asc, func
+from sqlalchemy.exc import IntegrityError
from .models import Account, EmailService, RegistrationTask, Setting, Proxy, CpaService, Sub2ApiService
@@ -14,6 +15,59 @@
# 账户 CRUD
# ============================================================================
+AccountConflictMode = Literal["raise", "return", "merge"]
+
+
+def _has_meaningful_account_value(value: Any) -> bool:
+ """判断账号字段值是否值得回写到已有记录。"""
+ if value is None:
+ return False
+ if isinstance(value, str):
+ return bool(value.strip())
+ if isinstance(value, dict):
+ return bool(value)
+ return True
+
+
+def _merge_account_payload(existing: Account, payload: Dict[str, Any]) -> None:
+ """将新账号载荷中的有效字段合并到已有账号记录。"""
+ merge_fields = (
+ "password",
+ "access_token",
+ "refresh_token",
+ "id_token",
+ "session_token",
+ "client_id",
+ "account_id",
+ "workspace_id",
+ "email_service",
+ "email_service_id",
+ "proxy_used",
+ "expires_at",
+ "status",
+ "source",
+ "cookies",
+ )
+
+ for field in merge_fields:
+ value = payload.get(field)
+ if _has_meaningful_account_value(value):
+ setattr(existing, field, value)
+
+ incoming_extra_data = payload.get("extra_data")
+ if _has_meaningful_account_value(incoming_extra_data):
+ merged_extra_data = dict(existing.extra_data or {})
+ merged_extra_data.update(incoming_extra_data)
+ existing.extra_data = merged_extra_data
+
+ incoming_registered_at = payload.get("registered_at")
+ incoming_status = payload.get("status")
+ if (
+ incoming_registered_at is not None
+ and (existing.registered_at is None or (existing.status == "failed" and incoming_status == "active"))
+ ):
+ existing.registered_at = incoming_registered_at
+
def create_account(
db: Session,
email: str,
@@ -32,10 +86,11 @@ def create_account(
expires_at: Optional['datetime'] = None,
extra_data: Optional[Dict[str, Any]] = None,
status: Optional[str] = None,
- source: Optional[str] = None
+ source: Optional[str] = None,
+ if_exists: AccountConflictMode = "raise",
) -> Account:
- """创建新账户"""
- db_account = Account(
+ """创建新账户,可按策略处理邮箱重复。"""
+ payload = dict(
email=email,
password=password,
client_id=client_id,
@@ -53,12 +108,39 @@ def create_account(
extra_data=extra_data or {},
status=status or 'active',
source=source or 'register',
- registered_at=datetime.utcnow()
+ registered_at=datetime.utcnow(),
)
+
+ if if_exists != "raise":
+ existing = get_account_by_email(db, email)
+ if existing is not None:
+ if if_exists == "merge":
+ _merge_account_payload(existing, payload)
+ db.commit()
+ db.refresh(existing)
+ return existing
+
+ db_account = Account(**payload)
db.add(db_account)
- db.commit()
- db.refresh(db_account)
- return db_account
+ try:
+ db.commit()
+ db.refresh(db_account)
+ return db_account
+ except IntegrityError:
+ db.rollback()
+ if if_exists == "raise":
+ raise
+
+ existing = get_account_by_email(db, email)
+ if existing is None:
+ raise
+
+ if if_exists == "merge":
+ _merge_account_payload(existing, payload)
+ db.commit()
+ db.refresh(existing)
+
+ return existing
def get_account_by_id(db: Session, account_id: int) -> Optional[Account]:
diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py
index 5163d1e9..94dcc402 100644
--- a/src/web/routes/registration.py
+++ b/src/web/routes/registration.py
@@ -254,6 +254,9 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy:
# 更新 TaskManager 状态
task_manager.update_status(task_uuid, "running")
+ # 优先使用任务已绑定的邮箱服务,避免批量 Outlook 任务在并发下抢同一邮箱。
+ bound_email_service_id = email_service_id or getattr(task, "email_service_id", None)
+
# 确定使用的代理
# 如果前端传入了代理参数,使用传入的
# 否则从代理列表或系统设置中获取
@@ -273,10 +276,10 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy:
settings = get_settings()
# 优先使用数据库中配置的邮箱服务
- if email_service_id:
+ if bound_email_service_id:
from ...database.models import EmailService as EmailServiceModel
db_service = db.query(EmailServiceModel).filter(
- EmailServiceModel.id == email_service_id,
+ EmailServiceModel.id == bound_email_service_id,
EmailServiceModel.enabled == True
).first()
@@ -287,7 +290,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy:
crud.update_registration_task(db, task_uuid, email_service_id=db_service.id)
logger.info(f"使用数据库邮箱服务: {db_service.name} (ID: {db_service.id}, 类型: {service_type.value})")
else:
- raise ValueError(f"邮箱服务不存在或已禁用: {email_service_id}")
+ raise ValueError(f"邮箱服务不存在或已禁用: {bound_email_service_id}")
else:
# 使用默认配置或传入的配置
if service_type == EmailServiceType.TEMPMAIL:
diff --git a/tests/test_account_crud.py b/tests/test_account_crud.py
new file mode 100644
index 00000000..0100a5c6
--- /dev/null
+++ b/tests/test_account_crud.py
@@ -0,0 +1,153 @@
+from pathlib import Path
+from contextlib import contextmanager
+from types import SimpleNamespace
+
+from src.database import crud
+from src.core import register as register_module
+from src.core.register import RegistrationEngine, RegistrationResult
+from src.database.models import Account, Base
+from src.database.session import DatabaseSessionManager
+
+
+def _build_session(db_name: str):
+ runtime_dir = Path("tests_runtime")
+ runtime_dir.mkdir(exist_ok=True)
+
+ db_path = runtime_dir / db_name
+ if db_path.exists():
+ db_path.unlink()
+
+ manager = DatabaseSessionManager(f"sqlite:///{db_path}")
+ Base.metadata.create_all(bind=manager.engine)
+ return manager.SessionLocal()
+
+
+def test_create_account_merge_existing_email_updates_non_empty_fields():
+ session = _build_session("account_crud_merge.db")
+ try:
+ created = crud.create_account(
+ session,
+ email="dup@example.com",
+ password="old-pass",
+ email_service="outlook",
+ refresh_token="refresh-old",
+ proxy_used="http://old-proxy",
+ status="failed",
+ extra_data={"first": True},
+ )
+
+ merged = crud.create_account(
+ session,
+ email="dup@example.com",
+ password="new-pass",
+ email_service="outlook",
+ access_token="access-new",
+ refresh_token=None,
+ proxy_used="",
+ status="active",
+ extra_data={"second": True},
+ if_exists="merge",
+ )
+
+ assert merged.id == created.id
+ assert merged.password == "new-pass"
+ assert merged.access_token == "access-new"
+ assert merged.refresh_token == "refresh-old"
+ assert merged.proxy_used == "http://old-proxy"
+ assert merged.status == "active"
+ assert merged.extra_data == {"first": True, "second": True}
+ finally:
+ session.close()
+
+
+def test_create_account_return_existing_email_keeps_original_record():
+ session = _build_session("account_crud_return.db")
+ try:
+ created = crud.create_account(
+ session,
+ email="existing@example.com",
+ password="saved-pass",
+ email_service="outlook",
+ status="active",
+ )
+
+ returned = crud.create_account(
+ session,
+ email="existing@example.com",
+ password="new-pass",
+ email_service="outlook",
+ status="failed",
+ if_exists="return",
+ )
+
+ assert returned.id == created.id
+ assert returned.password == "saved-pass"
+ assert returned.status == "active"
+ finally:
+ session.close()
+
+
+def test_registration_engine_save_to_database_merges_duplicate_email(monkeypatch):
+ session = _build_session("registration_save_merge.db")
+ manager = session.bind
+ session.close()
+
+ @contextmanager
+ def fake_get_db():
+ db = DatabaseSessionManager(f"{manager.url}").SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
+
+ monkeypatch.setattr(
+ register_module,
+ "get_settings",
+ lambda: SimpleNamespace(openai_client_id="client-1"),
+ )
+ monkeypatch.setattr(register_module, "get_db", fake_get_db)
+
+ engine = RegistrationEngine.__new__(RegistrationEngine)
+ engine.email_service = SimpleNamespace(service_type=SimpleNamespace(value="outlook"))
+ engine.email_info = {"service_id": "mailbox-1"}
+ engine.proxy_url = "http://proxy-2"
+ engine._dump_session_cookies = lambda: "cookie-new"
+ engine._log = lambda *args, **kwargs: None
+
+ first = RegistrationResult(
+ success=True,
+ email="merge@example.com",
+ password="old-pass",
+ access_token="access-old",
+ refresh_token="refresh-old",
+ session_token="session-old",
+ metadata={"first": True},
+ source="register",
+ )
+ second = RegistrationResult(
+ success=True,
+ email="merge@example.com",
+ password="new-pass",
+ access_token="access-new",
+ refresh_token="",
+ session_token="session-new",
+ metadata={"second": True},
+ source="login",
+ )
+
+ assert engine.save_to_database(first) is True
+ assert engine.save_to_database(second) is True
+
+ verify_session = DatabaseSessionManager(f"{manager.url}").SessionLocal()
+ try:
+ account = verify_session.query(Account).filter(Account.email == "merge@example.com").one()
+ assert account.password == "new-pass"
+ assert account.access_token == "access-new"
+ assert account.refresh_token == "refresh-old"
+ assert account.session_token == "session-new"
+ assert account.cookies == "cookie-new"
+ assert account.client_id == "client-1"
+ assert account.source == "login"
+ assert account.extra_data == {"first": True, "second": True}
+ finally:
+ verify_session.close()
diff --git a/tests/test_registration_task_binding.py b/tests/test_registration_task_binding.py
new file mode 100644
index 00000000..32909870
--- /dev/null
+++ b/tests/test_registration_task_binding.py
@@ -0,0 +1,99 @@
+from contextlib import contextmanager
+from pathlib import Path
+from types import SimpleNamespace
+
+from src.database.models import Base, EmailService, RegistrationTask
+from src.database.session import DatabaseSessionManager
+from src.web.routes import registration as registration_routes
+
+
+def _build_manager(db_name: str) -> DatabaseSessionManager:
+ runtime_dir = Path("tests_runtime")
+ runtime_dir.mkdir(exist_ok=True)
+
+ db_path = runtime_dir / db_name
+ if db_path.exists():
+ db_path.unlink()
+
+ manager = DatabaseSessionManager(f"sqlite:///{db_path}")
+ Base.metadata.create_all(bind=manager.engine)
+ return manager
+
+
+def test_sync_registration_task_prefers_bound_email_service_id(monkeypatch):
+ manager = _build_manager("registration_task_binding.db")
+
+ with manager.session_scope() as session:
+ first = EmailService(
+ service_type="outlook",
+ name="first@example.com",
+ config={"email": "first@example.com", "password": "pass-1"},
+ enabled=True,
+ priority=0,
+ )
+ second = EmailService(
+ service_type="outlook",
+ name="second@example.com",
+ config={"email": "second@example.com", "password": "pass-2"},
+ enabled=True,
+ priority=1,
+ )
+ session.add_all([first, second])
+ session.flush()
+
+ task = RegistrationTask(
+ task_uuid="task-bound-service",
+ status="pending",
+ email_service_id=second.id,
+ )
+ session.add(task)
+
+ @contextmanager
+ def fake_get_db():
+ session = manager.SessionLocal()
+ try:
+ yield session
+ finally:
+ session.close()
+
+ captured = {}
+
+ def fake_create(service_type, config):
+ captured["service_type"] = service_type.value if hasattr(service_type, "value") else str(service_type)
+ captured["email"] = config.get("email")
+ return SimpleNamespace(service_type=service_type, config=config)
+
+ class FakeEngine:
+ def __init__(self, email_service, proxy_url=None, callback_logger=None, task_uuid=None):
+ self.email_service = email_service
+
+ def run(self):
+ return SimpleNamespace(
+ success=False,
+ email="",
+ error_message="stop after selection",
+ to_dict=lambda: {},
+ )
+
+ monkeypatch.setattr(registration_routes, "get_db", fake_get_db)
+ monkeypatch.setattr(registration_routes, "get_settings", lambda: SimpleNamespace())
+ monkeypatch.setattr(registration_routes, "get_proxy_for_registration", lambda db: (None, None))
+ monkeypatch.setattr(registration_routes.EmailServiceFactory, "create", fake_create)
+ monkeypatch.setattr(registration_routes, "RegistrationEngine", FakeEngine)
+ monkeypatch.setattr(registration_routes.task_manager, "update_status", lambda *args, **kwargs: None)
+ monkeypatch.setattr(
+ registration_routes.task_manager,
+ "create_log_callback",
+ lambda *args, **kwargs: (lambda message: None),
+ )
+
+ registration_routes._run_sync_registration_task(
+ task_uuid="task-bound-service",
+ email_service_type="outlook",
+ proxy=None,
+ email_service_config=None,
+ email_service_id=None,
+ )
+
+ assert captured["service_type"] == "outlook"
+ assert captured["email"] == "second@example.com"