diff --git a/README.md b/README.md index 60c0a5f..9ed9aba 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ - 单个账号导出为独立 `.json` 文件 - 多个 CPA 账号打包为 `.zip`,每个账号一个独立文件 - Sub2API 格式所有账号合并为单个 JSON + - Codex Auth 格式需先在账号管理中手动执行 `Codex Auth 登录` 成功后才能导出 - 上传目标(直连不走代理): - **CPA**:支持多服务配置,上传时选择目标服务,可按服务开关将账号实际代理写入 auth file 的 `proxy_url` - **Sub2API**:支持多服务配置,标准 sub2api-data 格式 diff --git a/src/config/constants.py b/src/config/constants.py index c0c4f91..d6b3be7 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -59,6 +59,11 @@ OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" OAUTH_REDIRECT_URI = "http://localhost:15555/auth/callback" OAUTH_SCOPE = "openid email profile offline_access" +# Codex CLI 专用 OAuth 参数(用于生成 Codex 兼容的 auth.json) +CODEX_OAUTH_REDIRECT_URI = "http://localhost:1455/auth/callback" +CODEX_OAUTH_SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke" +CODEX_OAUTH_ORIGINATOR = "codex_cli_rs" + # OpenAI API 端点 OPENAI_API_ENDPOINTS = { "sentinel": "https://sentinel.openai.com/backend-api/sentinel/req", diff --git a/src/core/codex_auth.py b/src/core/codex_auth.py new file mode 100644 index 0000000..276ee3b --- /dev/null +++ b/src/core/codex_auth.py @@ -0,0 +1,213 @@ +""" +Codex Auth 登录引擎 +复用仓库里已经验证通过的登录状态流,为已有账号生成 Codex CLI 可用的 auth.json。 +""" + +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + +from .openai.oauth import OAuthManager +from .register import PhaseContext, RegistrationEngine +from ..config.constants import ( + CODEX_OAUTH_ORIGINATOR, + CODEX_OAUTH_REDIRECT_URI, + CODEX_OAUTH_SCOPE, +) +from ..config.settings import get_settings +from ..services.base import BaseEmailService + + +@dataclass +class CodexAuthResult: + """Codex Auth 登录结果""" + + success: bool + email: str = "" + workspace_id: str = "" + auth_json: Optional[Dict[str, Any]] = None + error_message: str = "" + logs: List[str] = field(default_factory=list) + + +class CodexAuthEngine(RegistrationEngine): + """ + 对已有账号执行 Codex CLI 兼容 OAuth 登录流程。 + + 这里直接复用 RegistrationEngine 中已经跑通的: + 登录重入 → 密码校验 → OTP 校验 → consent/workspace → callback + 这条链路,避免与成功路径产生分叉。 + """ + + def __init__( + self, + email: str, + password: str, + email_service: BaseEmailService, + proxy_url: Optional[str] = None, + callback_logger: Optional[Callable[[str], None]] = None, + email_service_id: Optional[str] = None, + ): + super().__init__( + email_service=email_service, + proxy_url=proxy_url, + callback_logger=callback_logger, + ) + self.email = email + self.password = password + self.email_service_id = email_service_id + self.email_info = {"email": email} + if email_service_id: + self.email_info["service_id"] = email_service_id + + settings = get_settings() + self.oauth_manager = OAuthManager( + client_id=settings.openai_client_id, + auth_url=settings.openai_auth_url, + token_url=settings.openai_token_url, + redirect_uri=CODEX_OAUTH_REDIRECT_URI, + scope=CODEX_OAUTH_SCOPE, + proxy_url=proxy_url, + originator=CODEX_OAUTH_ORIGINATOR, + ) + + def _build_auth_json(self, token_info: Dict[str, Any]) -> Dict[str, Any]: + """构造 Codex CLI 兼容的 auth.json 内容。""" + now_rfc3339 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + return { + "auth_mode": "chatgpt", + "OPENAI_API_KEY": None, + "tokens": { + "id_token": token_info.get("id_token", ""), + "access_token": token_info.get("access_token", ""), + "refresh_token": token_info.get("refresh_token", ""), + "account_id": token_info.get("account_id", ""), + }, + "last_refresh": now_rfc3339, + } + + def _resolve_workspace_id(self, consent_url: Optional[str]) -> Optional[str]: + """ + OTP 校验成功后优先请求 consent 页面提取 workspace。 + 若页面未显式暴露 workspace_id,再回退到 Cookie 解析路径。 + """ + if not self.session or not self.oauth_start: + return None + + auth_target = consent_url or self.oauth_start.auth_url + try: + self._log(f"请求 consent 页面: {auth_target[:120]}...") + started_at = time.time() + response = self.session.get(auth_target, timeout=20) + self._log_timed_http_result("获取 consent 页面", started_at, response) + + workspace_id = self._extract_workspace_id_from_response( + response=response, + html=response.text or "", + url=str(getattr(response, "url", "") or "").strip(), + ) + if workspace_id: + self._log(f"Workspace ID: {workspace_id}") + return workspace_id + except Exception as e: + self._log(f"请求 consent 页面失败: {e}", "warning") + + self._log("consent 页面缺少 workspace_id,回退到 Cookie 解析路径", "warning") + return RegistrationEngine._get_workspace_id(self) + + def run(self) -> CodexAuthResult: + """执行 Codex Auth 登录并产出 auth.json。""" + result = CodexAuthResult(success=False, email=self.email, logs=self.logs) + + try: + self._log("=" * 50) + self._log(f"开始 Codex Auth 登录: {self.email}") + self._log("=" * 50) + + self._log("1. 初始化会话...") + if not RegistrationEngine._init_session(self): + result.error_message = "初始化会话失败" + return result + + self._log("2. 开始 Codex OAuth 流程...") + if not RegistrationEngine._start_oauth(self): + result.error_message = "OAuth 流程启动失败" + return result + + self._log("3. 获取 Device ID...") + did = RegistrationEngine._get_device_id(self) + if not did: + result.error_message = "获取 Device ID 失败" + return result + + self._log("4. 重新进入登录流程...") + if not self._try_reenter_login_flow(): + result.error_message = "进入登录流程失败" + return result + + self._log("5. 提交密码...") + self._otp_sent_at = time.time() + if not self._submit_login_password_step(): + result.error_message = "密码验证失败" + return result + + self._log("6. 等待验证码...") + otp_started_at = time.time() + code, otp_phase = self._phase_otp_secondary( + PhaseContext(otp_sent_at=self._otp_sent_at), + started_at=otp_started_at, + ) + if not code: + result.error_message = otp_phase.error_message or "获取验证码失败" + return result + + self._log("7. 验证验证码...") + otp_valid, consent_url = self._validate_verification_code_and_get_continue_url(code) + if not otp_valid: + result.error_message = "验证码校验失败" + return result + + self._log("8. 获取 Workspace ID...") + workspace_id = self._resolve_workspace_id(consent_url) + if not workspace_id: + result.error_message = "获取 Workspace ID 失败" + return result + result.workspace_id = workspace_id + + self._log("9. 选择 Workspace...") + continue_url = RegistrationEngine._select_workspace(self, workspace_id) + if not continue_url: + result.error_message = "选择 Workspace 失败" + return result + + self._log("10. 跟随重定向...") + callback_url = RegistrationEngine._follow_redirects(self, continue_url) + if not callback_url: + result.error_message = "获取回调 URL 失败" + return result + + self._log("11. 处理 OAuth 回调...") + token_info = RegistrationEngine._handle_oauth_callback(self, callback_url) + if not token_info: + result.error_message = "OAuth 回调处理失败" + return result + + result.auth_json = self._build_auth_json(token_info) + result.success = True + + self._log("=" * 50) + self._log(f"Codex Auth 登录成功: {self.email}") + self._log(f"Account ID: {token_info.get('account_id', '')}") + self._log(f"Workspace ID: {workspace_id}") + self._log("=" * 50) + return result + + except Exception as e: + self._log(f"Codex Auth 登录异常: {e}", "error") + result.error_message = str(e) + return result + finally: + try: + self.http_client.close() + except Exception: + pass diff --git a/src/core/openai/oauth.py b/src/core/openai/oauth.py index e8dc0fa..5a2c618 100644 --- a/src/core/openai/oauth.py +++ b/src/core/openai/oauth.py @@ -190,7 +190,8 @@ def generate_oauth_url( *, redirect_uri: str = OAUTH_REDIRECT_URI, scope: str = OAUTH_SCOPE, - client_id: str = OAUTH_CLIENT_ID + client_id: str = OAUTH_CLIENT_ID, + originator: Optional[str] = None ) -> OAuthStart: """ 生成 OAuth 授权 URL @@ -199,6 +200,7 @@ def generate_oauth_url( redirect_uri: 回调地址 scope: 权限范围 client_id: OpenAI Client ID + originator: 来源标识(如 codex_cli_rs) Returns: OAuthStart 对象,包含授权 URL 和必要参数 @@ -219,6 +221,8 @@ def generate_oauth_url( "id_token_add_organizations": "true", "codex_cli_simplified_flow": "true", } + if originator: + params["originator"] = originator auth_url = f"{OAUTH_AUTH_URL}?{urllib.parse.urlencode(params)}" return OAuthStart( auth_url=auth_url, @@ -321,7 +325,8 @@ class OAuthManager: token_url: str = OAUTH_TOKEN_URL, redirect_uri: str = OAUTH_REDIRECT_URI, scope: str = OAUTH_SCOPE, - proxy_url: Optional[str] = None + proxy_url: Optional[str] = None, + originator: Optional[str] = None ): self.client_id = client_id self.auth_url = auth_url @@ -329,13 +334,15 @@ class OAuthManager: self.redirect_uri = redirect_uri self.scope = scope self.proxy_url = proxy_url + self.originator = originator def start_oauth(self) -> OAuthStart: """开始 OAuth 流程""" return generate_oauth_url( redirect_uri=self.redirect_uri, scope=self.scope, - client_id=self.client_id + client_id=self.client_id, + originator=self.originator ) def handle_callback( diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index 7b4aa6f..85cb290 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -1,12 +1,14 @@ """ 账号管理 API 路由 """ +import asyncio import io import json import logging +import threading import zipfile from datetime import datetime -from typing import List, Optional +from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Query, BackgroundTasks, Body from fastapi.responses import StreamingResponse @@ -29,6 +31,98 @@ logger = logging.getLogger(__name__) router = APIRouter() +def _get_account_extra_data(account: Account) -> Dict[str, Any]: + extra_data = account.extra_data + if isinstance(extra_data, dict): + return dict(extra_data) + return {} + + +def _build_codex_auth_extra_data( + existing_extra_data: Optional[Dict[str, Any]], + *, + workspace_id: str = "", + generated_at: Optional[datetime] = None, +) -> Dict[str, Any]: + extra_data = dict(existing_extra_data or {}) + codex_auth = dict(extra_data.get("codex_auth") or {}) + codex_auth["generated"] = True + codex_auth["generated_at"] = (generated_at or datetime.utcnow()).isoformat() + if workspace_id: + codex_auth["workspace_id"] = workspace_id + extra_data["codex_auth"] = codex_auth + return extra_data + + +def _has_generated_codex_auth(account: Account) -> bool: + codex_auth = _get_account_extra_data(account).get("codex_auth") + return isinstance(codex_auth, dict) and bool(codex_auth.get("generated")) + + +def _ensure_codex_auth_export_ready(accounts: List[Account]) -> None: + missing = [acc.email for acc in accounts if not _has_generated_codex_auth(acc)] + if not missing: + return + + missing_summary = "、".join(missing[:10]) + if len(missing) > 10: + missing_summary += f" 等 {len(missing)} 个账号" + + raise HTTPException( + status_code=400, + detail=( + "以下账号尚未生成 Codex Auth,请先在账号管理中点击「Codex Auth 登录」后再导出:" + f"{missing_summary}" + ), + ) + + +def _persist_codex_auth_result( + db, + *, + account_id: int, + auth_json: Dict[str, Any], + workspace_id: str = "", +) -> None: + account = crud.get_account_by_id(db, account_id) + if not account: + raise ValueError(f"账号不存在: {account_id}") + + tokens = auth_json.get("tokens") or {} + openai_account_id = str(tokens.get("account_id") or "").strip() + workspace_id = str(workspace_id or "").strip() + + update_kwargs = { + "access_token": tokens.get("access_token", ""), + "refresh_token": tokens.get("refresh_token", ""), + "id_token": tokens.get("id_token", ""), + "last_refresh": datetime.utcnow(), + "extra_data": _build_codex_auth_extra_data( + _get_account_extra_data(account), + workspace_id=workspace_id, + ), + } + if openai_account_id: + update_kwargs["account_id"] = openai_account_id + if workspace_id: + update_kwargs["workspace_id"] = workspace_id + + for key, value in update_kwargs.items(): + setattr(account, key, value) + + token_values = { + "access_token": account.access_token, + "refresh_token": account.refresh_token, + "id_token": account.id_token, + "session_token": account.session_token, + } + account.token_sync_status = "pending" if any(token_values.values()) else "not_ready" + account.token_sync_updated_at = datetime.utcnow() + + db.commit() + db.refresh(account) + + def _get_proxy(request_proxy: Optional[str] = None) -> Optional[str]: """获取代理 URL,策略与注册流程一致:代理列表 → 动态代理 → 静态配置""" if request_proxy: @@ -537,6 +631,351 @@ async def export_accounts_cpa(request: BatchExportRequest): ) +@router.post("/export/codex_auth") +async def export_accounts_codex_auth(request: BatchExportRequest): + """导出账号为 Codex CLI auth.json 格式""" + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + accounts = db.query(Account).filter(Account.id.in_(ids)).all() + if not accounts: + raise HTTPException(status_code=400, detail="没有可导出的账号") + + _ensure_codex_auth_export_ready(accounts) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + def build_auth_json(acc): + return { + "auth_mode": "chatgpt", + "OPENAI_API_KEY": None, + "tokens": { + "id_token": acc.id_token or "", + "access_token": acc.access_token or "", + "refresh_token": acc.refresh_token or "", + "account_id": acc.account_id or "" + }, + "last_refresh": acc.last_refresh.isoformat() if acc.last_refresh else "" + } + + if len(accounts) == 1: + acc = accounts[0] + auth_data = build_auth_json(acc) + content = json.dumps(auth_data, ensure_ascii=False, indent=2) + filename = "auth.json" + return StreamingResponse( + iter([content]), + media_type="application/json", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + # 多个账号打包为 ZIP + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for acc in accounts: + auth_data = build_auth_json(acc) + content = json.dumps(auth_data, ensure_ascii=False, indent=2) + zf.writestr(f"{acc.email}/auth.json", content) + + zip_buffer.seek(0) + zip_filename = f"codex_auth_{timestamp}.zip" + return StreamingResponse( + zip_buffer, + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename={zip_filename}"} + ) + + +# ============== Codex Auth 登录导出 ============== + +def _build_email_service_for_account(db, account: Account): + """根据账号的邮箱服务类型,复用收件箱逻辑构建邮箱服务实例(用于读取 OTP)""" + from ...services import EmailServiceFactory, EmailServiceType + + email_service_type = account.email_service + if not email_service_type: + raise ValueError(f"账号 {account.email} 没有关联的邮箱服务类型") + + try: + service_type = EmailServiceType(email_service_type) + except ValueError: + raise ValueError(f"不支持的邮箱服务类型: {email_service_type}") + + config = _build_inbox_config(db, service_type, account.email) + if config is None: + raise ValueError(f"未找到可用的 {email_service_type} 邮箱服务配置") + + # 添加代理 + proxy_url = _get_proxy() + if proxy_url and 'proxy_url' not in config: + config['proxy_url'] = proxy_url + + return EmailServiceFactory.create(service_type, config) + + +class CodexAuthLoginRequest(BaseModel): + """Codex Auth 登录请求""" + account_id: int + + +@router.post("/codex-auth-login") +async def codex_auth_login(request: CodexAuthLoginRequest): + """ + 对指定账号执行 Codex CLI 登录流程,获取 Codex 兼容的 auth.json。 + 使用 SSE 推送实时日志,最终返回 auth.json 数据。 + """ + import queue + + with get_db() as db: + account = db.query(Account).filter(Account.id == request.account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + if not account.password: + raise HTTPException(status_code=400, detail=f"账号 {account.email} 没有密码,无法登录") + + # 提取需要的数据(避免跨线程 session 问题) + email = account.email + password = account.password + account_db_id = account.id + email_svc_id = account.email_service_id + + try: + email_service = _build_email_service_for_account(db, account) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + proxy_url = _get_proxy() + log_queue = queue.Queue() + + def log_callback(msg: str): + log_queue.put(("log", msg)) + + def run_login(): + from ...core.codex_auth import CodexAuthEngine + try: + engine = CodexAuthEngine( + email=email, + password=password, + email_service=email_service, + proxy_url=proxy_url, + callback_logger=log_callback, + email_service_id=email_svc_id, + ) + result = engine.run() + log_queue.put(("result", { + "success": result.success, + "email": result.email, + "workspace_id": result.workspace_id, + "auth_json": result.auth_json, + "error_message": result.error_message, + })) + except Exception as e: + log_queue.put(("result", { + "success": False, + "email": email, + "workspace_id": "", + "auth_json": None, + "error_message": str(e), + })) + + async def event_generator(): + thread = threading.Thread(target=run_login, daemon=True) + thread.start() + + while True: + try: + # 非阻塞轮询队列 + try: + msg_type, msg_data = log_queue.get_nowait() + except queue.Empty: + await asyncio.sleep(0.3) + if not thread.is_alive() and log_queue.empty(): + break + continue + + if msg_type == "log": + yield f"data: {json.dumps({'type': 'log', 'message': msg_data}, ensure_ascii=False)}\n\n" + elif msg_type == "result": + # 如果登录成功,同时更新数据库中的 token + if msg_data["success"] and msg_data["auth_json"]: + try: + with get_db() as db: + _persist_codex_auth_result( + db, + account_id=account_db_id, + auth_json=msg_data["auth_json"], + workspace_id=str(msg_data.get("workspace_id") or "").strip(), + ) + except Exception as e: + logger.warning(f"更新数据库 token 失败: {e}") + + yield f"data: {json.dumps({'type': 'result', **msg_data}, ensure_ascii=False)}\n\n" + break + except Exception: + break + + thread.join(timeout=5) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +class CodexAuthBatchRequest(BaseModel): + """批量 Codex Auth 登录请求""" + ids: List[int] = [] + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + + +@router.post("/codex-auth-login/batch") +async def codex_auth_login_batch(request: CodexAuthBatchRequest): + """ + 批量 Codex Auth 登录。 + 逐个执行登录,通过 SSE 推送每个账号的进度和结果。 + 全部完成后打包下载。 + """ + import queue + + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + accounts_data = [] + for acc in db.query(Account).filter(Account.id.in_(ids)).all(): + if not acc.password: + continue + accounts_data.append({ + "id": acc.id, + "email": acc.email, + "password": acc.password, + "email_service": acc.email_service, + "email_service_id": acc.email_service_id, + }) + + if not accounts_data: + raise HTTPException(status_code=400, detail="没有符合条件的账号(需要有密码)") + + log_queue = queue.Queue() + + def run_batch(): + from ...core.codex_auth import CodexAuthEngine + results = [] + + for i, acc_data in enumerate(accounts_data): + log_queue.put(("progress", { + "current": i + 1, + "total": len(accounts_data), + "email": acc_data["email"], + })) + + try: + with get_db() as db: + account = db.query(Account).filter(Account.id == acc_data["id"]).first() + if not account: + continue + email_service = _build_email_service_for_account(db, account) + + proxy_url = _get_proxy() + + def log_cb(msg, email=acc_data["email"]): + log_queue.put(("log", f"[{email}] {msg}")) + + engine = CodexAuthEngine( + email=acc_data["email"], + password=acc_data["password"], + email_service=email_service, + proxy_url=proxy_url, + callback_logger=log_cb, + email_service_id=acc_data.get("email_service_id"), + ) + result = engine.run() + + if result.success and result.auth_json: + # 更新数据库 + try: + with get_db() as db: + _persist_codex_auth_result( + db, + account_id=acc_data["id"], + auth_json=result.auth_json, + workspace_id=str(result.workspace_id or "").strip(), + ) + except Exception as e: + logger.warning(f"更新数据库 token 失败: {e}") + + results.append({ + "email": acc_data["email"], + "workspace_id": result.workspace_id, + "auth_json": result.auth_json, + }) + log_queue.put(("account_result", { + "email": acc_data["email"], + "success": True, + })) + else: + log_queue.put(("account_result", { + "email": acc_data["email"], + "success": False, + "error": result.error_message, + })) + + except Exception as e: + log_queue.put(("account_result", { + "email": acc_data["email"], + "success": False, + "error": str(e), + })) + + log_queue.put(("batch_done", results)) + + async def event_generator(): + thread = threading.Thread(target=run_batch, daemon=True) + thread.start() + + while True: + try: + try: + msg_type, msg_data = log_queue.get_nowait() + except queue.Empty: + await asyncio.sleep(0.3) + if not thread.is_alive() and log_queue.empty(): + break + continue + + if msg_type == "batch_done": + yield f"data: {json.dumps({'type': 'batch_done', 'results': msg_data}, ensure_ascii=False)}\n\n" + break + else: + yield f"data: {json.dumps({'type': msg_type, **msg_data} if isinstance(msg_data, dict) else {'type': msg_type, 'message': msg_data}, ensure_ascii=False)}\n\n" + except Exception: + break + + thread.join(timeout=5) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + @router.get("/stats/summary") async def get_accounts_stats(): """获取账号统计信息""" diff --git a/static/js/accounts.js b/static/js/accounts.js index 10f83d4..fcdb6bf 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -111,6 +111,24 @@ function initEventListeners() { // 批量删除 elements.batchDeleteBtn.addEventListener('click', handleBatchDelete); + // Codex Auth 登录 + const codexAuthBtn = document.getElementById('codex-auth-login-btn'); + if (codexAuthBtn) { + codexAuthBtn.addEventListener('click', handleCodexAuthLogin); + } + const closeCodexAuthModal = document.getElementById('close-codex-auth-modal'); + if (closeCodexAuthModal) { + closeCodexAuthModal.addEventListener('click', () => { + document.getElementById('codex-auth-modal').classList.remove('active'); + }); + } + const closeCodexAuthModalBtn = document.getElementById('close-codex-auth-modal-btn'); + if (closeCodexAuthModalBtn) { + closeCodexAuthModalBtn.addEventListener('click', () => { + document.getElementById('codex-auth-modal').classList.remove('active'); + }); + } + // 全选(当前页) elements.selectAll.addEventListener('change', (e) => { const checkboxes = elements.table.querySelectorAll('input[type="checkbox"][data-id]'); @@ -481,7 +499,10 @@ function updateBatchButtons() { elements.batchCheckSubBtn.disabled = count === 0; elements.exportBtn.disabled = count === 0; - elements.batchDeleteBtn.textContent = count > 0 ? `🗑️ 删除 (${count})` : '🗑️ 批量删除'; + const codexAuthBtn = document.getElementById('codex-auth-login-btn'); + if (codexAuthBtn) codexAuthBtn.disabled = count === 0; + + elements.batchDeleteBtn.textContent = count > 0 ? `删除 (${count})` : '删除'; elements.batchRefreshBtn.textContent = count > 0 ? `🔄 刷新 (${count})` : '🔄 刷新Token'; elements.batchValidateBtn.textContent = count > 0 ? `✅ 验证 (${count})` : '✅ 验证Token'; elements.batchUploadBtn.textContent = count > 0 ? `☁️ 上传 (${count})` : '☁️ 上传'; @@ -724,7 +745,14 @@ async function exportAccounts(format) { }); if (!response.ok) { - throw new Error(`导出失败: HTTP ${response.status}`); + let errorMessage = `HTTP ${response.status}`; + try { + const errorData = await response.json(); + errorMessage = errorData.detail || errorData.message || errorMessage; + } catch (parseError) { + // ignore non-JSON error bodies and fall back to status text + } + throw new Error(errorMessage); } // 获取文件内容 @@ -732,7 +760,7 @@ async function exportAccounts(format) { // 从 Content-Disposition 获取文件名 const disposition = response.headers.get('Content-Disposition'); - let filename = `accounts_${Date.now()}.${(format === 'cpa' || format === 'sub2api') ? 'json' : format}`; + let filename = `accounts_${Date.now()}.${(format === 'cpa' || format === 'sub2api' || format === 'codex_auth') ? 'json' : format}`; if (disposition) { const match = disposition.match(/filename=(.+)/); if (match) { @@ -1266,3 +1294,188 @@ function showInboxCodeResult(code, email) { `; elements.detailModal.classList.add('active'); } + +// ============== Codex Auth 登录 ============== + +let codexAuthResults = []; + +async function handleCodexAuthLogin() { + const count = getEffectiveCount(); + if (count === 0) { + toast.warning('请先选择要登录的账号'); + return; + } + + const confirmed = await confirm(`将对选中的 ${count} 个账号执行 Codex Auth 登录(需要接收邮箱验证码),确定继续吗?`); + if (!confirmed) return; + + const modal = document.getElementById('codex-auth-modal'); + const logsEl = document.getElementById('codex-auth-logs'); + const statusEl = document.getElementById('codex-auth-status'); + const downloadBtn = document.getElementById('codex-auth-download-btn'); + + logsEl.textContent = ''; + statusEl.textContent = '正在启动 Codex Auth 登录...'; + downloadBtn.style.display = 'none'; + codexAuthResults = []; + modal.classList.add('active'); + + if (count === 1 && !selectAllPages) { + // 单账号登录 + const accountId = [...selectedAccounts][0]; + await codexAuthLoginSingle(accountId, logsEl, statusEl, downloadBtn); + } else { + // 批量登录 + await codexAuthLoginBatch(logsEl, statusEl, downloadBtn); + } +} + +async function codexAuthLoginSingle(accountId, logsEl, statusEl, downloadBtn) { + try { + const response = await fetch('/api/accounts/codex-auth-login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ account_id: accountId }), + }); + + if (!response.ok) { + const err = await response.json(); + statusEl.textContent = '登录失败: ' + (err.detail || response.statusText); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + try { + const data = JSON.parse(line.slice(6)); + if (data.type === 'log') { + logsEl.textContent += data.message + '\n'; + logsEl.scrollTop = logsEl.scrollHeight; + } else if (data.type === 'result') { + if (data.success && data.auth_json) { + statusEl.textContent = 'Codex Auth 登录成功!'; + codexAuthResults = [{ email: data.email, auth_json: data.auth_json }]; + downloadBtn.style.display = 'inline-block'; + downloadBtn.onclick = () => downloadCodexAuthResults(); + loadAccounts(); + } else { + statusEl.textContent = '登录失败: ' + (data.error_message || '未知错误'); + } + } + } catch (e) { /* ignore parse errors */ } + } + } + } catch (error) { + statusEl.textContent = '登录失败: ' + error.message; + } +} + +async function codexAuthLoginBatch(logsEl, statusEl, downloadBtn) { + try { + const payload = buildBatchPayload(); + const response = await fetch('/api/accounts/codex-auth-login/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const err = await response.json(); + statusEl.textContent = '批量登录失败: ' + (err.detail || response.statusText); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let successCount = 0; + let failCount = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + try { + const data = JSON.parse(line.slice(6)); + if (data.type === 'log') { + logsEl.textContent += data.message + '\n'; + logsEl.scrollTop = logsEl.scrollHeight; + } else if (data.type === 'progress') { + statusEl.textContent = `正在处理 ${data.current}/${data.total}: ${data.email}`; + } else if (data.type === 'account_result') { + if (data.success) { + successCount++; + logsEl.textContent += `[${data.email}] 登录成功\n`; + } else { + failCount++; + logsEl.textContent += `[${data.email}] 登录失败: ${data.error || '未知错误'}\n`; + } + logsEl.scrollTop = logsEl.scrollHeight; + } else if (data.type === 'batch_done') { + codexAuthResults = data.results || []; + statusEl.textContent = `批量登录完成: 成功 ${successCount}, 失败 ${failCount}`; + if (codexAuthResults.length > 0) { + downloadBtn.style.display = 'inline-block'; + downloadBtn.onclick = () => downloadCodexAuthResults(); + } + loadAccounts(); + } + } catch (e) { /* ignore parse errors */ } + } + } + } catch (error) { + statusEl.textContent = '批量登录失败: ' + error.message; + } +} + +function downloadCodexAuthResults() { + if (codexAuthResults.length === 0) return; + + if (codexAuthResults.length === 1) { + // 单个直接下载 auth.json + const item = codexAuthResults[0]; + const blob = new Blob([JSON.stringify(item.auth_json, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'auth.json'; + document.body.appendChild(a); + a.click(); + URL.revokeObjectURL(url); + a.remove(); + } else { + // 多个:逐个下载(浏览器端无法打 ZIP,逐个下载) + codexAuthResults.forEach((item, i) => { + setTimeout(() => { + const blob = new Blob([JSON.stringify(item.auth_json, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${item.email}_auth.json`; + document.body.appendChild(a); + a.click(); + URL.revokeObjectURL(url); + a.remove(); + }, i * 300); + }); + } + toast.success(`已下载 ${codexAuthResults.length} 个 auth.json`); +} diff --git a/templates/accounts.html b/templates/accounts.html index ed25960..f2e804a 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -139,7 +139,10 @@ +