Spaces:
Paused
Paused
| """ | |
| web_app.py - ChatGPT 注册机 Web UI 后端 | |
| FastAPI 主程序: | |
| - REST API:配置、注册、账号池、代理、结果查看 | |
| - WebSocket:注册日志、池管理日志实时推送 | |
| 启动: | |
| uv run uvicorn web_app:app --host 0.0.0.0 --port 8080 --reload | |
| 或 python web_app.py | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import base64 | |
| import binascii | |
| import hashlib | |
| import hmac | |
| import json | |
| import os | |
| import secrets | |
| import sys | |
| import threading | |
| import time | |
| from pathlib import Path | |
| from typing import Dict, List, Optional, Any | |
| import uvicorn | |
| from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Body, Request, Response | |
| from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, PlainTextResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| import register as reg | |
| # ============================================================ | |
| # 应用初始化 | |
| # ============================================================ | |
| app = FastAPI(title="ChatGPT 注册机 Web UI", version="1.0.0") | |
| _CORS_ALLOW_ORIGINS = [ | |
| o.strip() for o in os.environ.get( | |
| "CORS_ALLOW_ORIGINS", | |
| "http://localhost:8080,http://127.0.0.1:8080" | |
| ).split(",") if o.strip() | |
| ] | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=_CORS_ALLOW_ORIGINS, | |
| allow_credentials=True, | |
| allow_methods=["GET", "POST", "OPTIONS"], | |
| allow_headers=["Content-Type", "Authorization"], | |
| ) | |
| _BASE_DIR = Path(__file__).resolve().parent | |
| _TEMPLATES_DIR = _BASE_DIR / "templates" | |
| # ============================================================ | |
| # 全局任务状态 | |
| # ============================================================ | |
| # 注册任务 | |
| _reg_state: Dict[str, Any] = { | |
| "running": False, | |
| "success": 0, | |
| "fail": 0, | |
| "total": 0, | |
| "stop_event": None, | |
| "start_time": None, | |
| } | |
| _reg_log_queue: asyncio.Queue = asyncio.Queue(maxsize=1000) | |
| _reg_ws_clients: List[WebSocket] = [] | |
| # 账号池任务 | |
| _pool_state: Dict[str, Any] = { | |
| "running": False, | |
| "task": "", | |
| } | |
| _pool_log_queue: asyncio.Queue = asyncio.Queue(maxsize=1000) | |
| _pool_ws_clients: List[WebSocket] = [] | |
| _PROBE_RESULT_TTL_SEC = 120 | |
| # 事件循环引用 | |
| _event_loop: Optional[asyncio.AbstractEventLoop] = None | |
| # 运行时 JWT secret(当配置未提供时使用) | |
| _runtime_jwt_secret: str = "" | |
| def _b64url_encode(raw: bytes) -> str: | |
| return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") | |
| def _b64url_decode(data: str) -> bytes: | |
| pad = "=" * (-len(data) % 4) | |
| try: | |
| return base64.urlsafe_b64decode((data + pad).encode("ascii")) | |
| except (ValueError, binascii.Error) as e: | |
| raise HTTPException(status_code=401, detail="无效 token") from e | |
| def _get_auth_cfg() -> dict: | |
| cfg = reg.load_config() | |
| auth = cfg.get("auth") or {} | |
| return { | |
| "enabled": bool(auth.get("enabled", True)), | |
| "username": str(auth.get("username", "admin") or "admin"), | |
| "password_hash": str(auth.get("password_hash", "") or "").lower(), | |
| "jwt_secret": str(auth.get("jwt_secret", "") or ""), | |
| "jwt_exp_hours": int(auth.get("jwt_exp_hours", 24) or 24), | |
| "cookie_name": str(auth.get("cookie_name", "cc_auth") or "cc_auth"), | |
| } | |
| def _get_jwt_secret() -> str: | |
| auth = _get_auth_cfg() | |
| return auth["jwt_secret"] or _runtime_jwt_secret | |
| def _jwt_encode(payload: dict) -> str: | |
| header = {"alg": "HS256", "typ": "JWT"} | |
| h = _b64url_encode(json.dumps(header, separators=(",", ":")).encode("utf-8")) | |
| p = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")) | |
| signing_input = f"{h}.{p}".encode("ascii") | |
| sig = hmac.new(_get_jwt_secret().encode("utf-8"), signing_input, hashlib.sha256).digest() | |
| s = _b64url_encode(sig) | |
| return f"{h}.{p}.{s}" | |
| def _jwt_decode(token: str) -> dict: | |
| parts = token.split(".") | |
| if len(parts) != 3: | |
| raise HTTPException(status_code=401, detail="无效 token") | |
| h, p, s = parts | |
| signing_input = f"{h}.{p}".encode("ascii") | |
| expected = hmac.new(_get_jwt_secret().encode("utf-8"), signing_input, hashlib.sha256).digest() | |
| got = _b64url_decode(s) | |
| if not hmac.compare_digest(expected, got): | |
| raise HTTPException(status_code=401, detail="token 签名无效") | |
| payload_raw = _b64url_decode(p) | |
| try: | |
| payload = json.loads(payload_raw.decode("utf-8")) | |
| except Exception as e: | |
| raise HTTPException(status_code=401, detail="token payload 无效") from e | |
| exp = int(payload.get("exp", 0) or 0) | |
| if exp <= int(time.time()): | |
| raise HTTPException(status_code=401, detail="token 已过期") | |
| return payload | |
| def _issue_login_token(username: str) -> str: | |
| auth = _get_auth_cfg() | |
| now = int(time.time()) | |
| exp = now + max(1, auth["jwt_exp_hours"]) * 3600 | |
| payload = {"sub": username, "iat": now, "exp": exp} | |
| return _jwt_encode(payload) | |
| def _token_from_request(request: Request) -> str: | |
| auth = _get_auth_cfg() | |
| return (request.cookies.get(auth["cookie_name"]) or "").strip() | |
| def _current_user_from_request(request: Request) -> Optional[dict]: | |
| auth = _get_auth_cfg() | |
| if not auth["enabled"]: | |
| return {"sub": "anonymous"} | |
| token = _token_from_request(request) | |
| if not token: | |
| return None | |
| try: | |
| return _jwt_decode(token) | |
| except HTTPException: | |
| return None | |
| def _set_auth_cookie(response: Response, token: str, request: Request): | |
| auth = _get_auth_cfg() | |
| secure = request.url.scheme == "https" | |
| response.set_cookie( | |
| key=auth["cookie_name"], | |
| value=token, | |
| httponly=True, | |
| secure=secure, | |
| samesite="lax", | |
| max_age=max(1, auth["jwt_exp_hours"]) * 3600, | |
| path="/", | |
| ) | |
| def _clear_auth_cookie(response: Response): | |
| auth = _get_auth_cfg() | |
| response.delete_cookie(key=auth["cookie_name"], path="/") | |
| async def _startup(): | |
| global _event_loop, _runtime_jwt_secret | |
| _event_loop = asyncio.get_event_loop() | |
| _runtime_jwt_secret = secrets.token_urlsafe(48) | |
| async def _security_and_auth_middleware(request: Request, call_next): | |
| path = request.url.path | |
| # 允许预检与公开接口 | |
| if request.method == "OPTIONS" or path in {"/", "/api/auth/login", "/api/auth/me", "/api/auth/setup"}: | |
| response = await call_next(request) | |
| else: | |
| auth = _get_auth_cfg() | |
| if auth["enabled"] and path.startswith("/api/"): | |
| user = _current_user_from_request(request) | |
| if not user: | |
| raise HTTPException(status_code=401, detail="未登录或会话已失效") | |
| response = await call_next(request) | |
| # 常见安全响应头 | |
| response.headers["X-Content-Type-Options"] = "nosniff" | |
| response.headers["X-Frame-Options"] = "DENY" | |
| response.headers["Referrer-Policy"] = "no-referrer" | |
| response.headers["Cache-Control"] = "no-store" | |
| response.headers["Pragma"] = "no-cache" | |
| response.headers["Expires"] = "0" | |
| response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()" | |
| response.headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:" | |
| return response | |
| def _ws_current_user(ws: WebSocket) -> Optional[dict]: | |
| auth = _get_auth_cfg() | |
| if not auth["enabled"]: | |
| return {"sub": "anonymous"} | |
| token = (ws.cookies.get(auth["cookie_name"]) or "").strip() | |
| if not token: | |
| return None | |
| try: | |
| return _jwt_decode(token) | |
| except HTTPException: | |
| return None | |
| def _push_log_sync(queue: asyncio.Queue, msg: str): | |
| """从同步线程将日志推入 asyncio Queue""" | |
| if _event_loop and not _event_loop.is_closed(): | |
| try: | |
| asyncio.run_coroutine_threadsafe(queue.put(msg), _event_loop) | |
| except Exception: | |
| pass | |
| def _make_reg_log_cb(): | |
| return lambda msg: _push_log_sync(_reg_log_queue, msg) | |
| def _make_pool_log_cb(): | |
| return lambda msg: _push_log_sync(_pool_log_queue, msg) | |
| def _token_fingerprint(token: str) -> str: | |
| if not token: | |
| return "" | |
| return hashlib.sha256(token.encode("utf-8")).hexdigest()[:12] | |
| def _build_probe_signature(base_url: str, token: str, target_type: str, proxy: str) -> str: | |
| raw = "|".join([ | |
| str(base_url or "").strip().rstrip("/"), | |
| str(target_type or "").strip().lower(), | |
| _token_fingerprint(str(token or "").strip()), | |
| str(proxy or "").strip(), | |
| ]) | |
| return hashlib.sha256(raw.encode("utf-8")).hexdigest() | |
| def _make_reg_progress_cb(): | |
| def cb(success: int, fail: int, total: int): | |
| _reg_state["success"] = success | |
| _reg_state["fail"] = fail | |
| _reg_state["total"] = total | |
| return cb | |
| # ============================================================ | |
| # 静态文件 & 首页 | |
| # ============================================================ | |
| async def index(): | |
| html_path = _TEMPLATES_DIR / "index.html" | |
| if not html_path.exists(): | |
| raise HTTPException(status_code=404, detail="index.html 不存在") | |
| return HTMLResponse(content=html_path.read_text(encoding="utf-8")) | |
| async def auth_login(body: dict = Body(...), request: Request = None): | |
| auth = _get_auth_cfg() | |
| username = str(body.get("username", "") or "").strip() | |
| password = str(body.get("password", "") or "") | |
| if not auth["enabled"]: | |
| return {"ok": True, "user": {"username": "anonymous"}, "auth_enabled": False, "need_setup": False} | |
| if not auth["password_hash"]: | |
| return {"ok": False, "auth_enabled": True, "need_setup": True} | |
| pwd_hash = hashlib.sha256(password.encode("utf-8")).hexdigest() | |
| user_ok = hmac.compare_digest(username, auth["username"]) | |
| pwd_ok = hmac.compare_digest(pwd_hash, auth["password_hash"]) | |
| if not (user_ok and pwd_ok): | |
| raise HTTPException(status_code=401, detail="用户名或密码错误") | |
| token = _issue_login_token(username) | |
| resp = JSONResponse({"ok": True, "user": {"username": username}, "auth_enabled": True, "need_setup": False}) | |
| _set_auth_cookie(resp, token, request) | |
| return resp | |
| async def auth_setup(body: dict = Body(...), request: Request = None): | |
| auth = _get_auth_cfg() | |
| if not auth["enabled"]: | |
| return {"ok": True, "auth_enabled": False, "need_setup": False} | |
| if auth["password_hash"]: | |
| raise HTTPException(status_code=409, detail="密码已设置,请直接登录") | |
| username = str(body.get("username", auth["username"]) or "").strip() or auth["username"] | |
| password = str(body.get("password", "") or "") | |
| password_confirm = str(body.get("password_confirm", "") or "") | |
| if len(password) < 8: | |
| raise HTTPException(status_code=400, detail="密码长度至少 8 位") | |
| if password != password_confirm: | |
| raise HTTPException(status_code=400, detail="两次密码输入不一致") | |
| cfg = reg.load_config() | |
| cfg_auth = cfg.get("auth") or {} | |
| cfg_auth.update({ | |
| "enabled": True, | |
| "username": username, | |
| "password_hash": hashlib.sha256(password.encode("utf-8")).hexdigest(), | |
| }) | |
| cfg["auth"] = cfg_auth | |
| if not reg.save_config(cfg): | |
| raise HTTPException(status_code=500, detail="首次密码保存失败") | |
| token = _issue_login_token(username) | |
| resp = JSONResponse({"ok": True, "auth_enabled": True, "need_setup": False, "user": {"username": username}}) | |
| _set_auth_cookie(resp, token, request) | |
| return resp | |
| async def auth_logout(): | |
| resp = JSONResponse({"ok": True}) | |
| _clear_auth_cookie(resp) | |
| return resp | |
| async def auth_me(request: Request): | |
| auth = _get_auth_cfg() | |
| if not auth["enabled"]: | |
| return { | |
| "ok": True, | |
| "authenticated": True, | |
| "user": {"username": "anonymous"}, | |
| "auth_enabled": False, | |
| "need_setup": False, | |
| } | |
| if not auth["password_hash"]: | |
| return { | |
| "ok": True, | |
| "authenticated": False, | |
| "auth_enabled": True, | |
| "need_setup": True, | |
| "user": {"username": auth["username"]}, | |
| } | |
| user = _current_user_from_request(request) | |
| if not user: | |
| return {"ok": True, "authenticated": False, "auth_enabled": True, "need_setup": False} | |
| return { | |
| "ok": True, | |
| "authenticated": True, | |
| "auth_enabled": True, | |
| "need_setup": False, | |
| "user": {"username": user.get("sub", "")}, | |
| } | |
| # ============================================================ | |
| # 配置 API | |
| # ============================================================ | |
| async def get_config(): | |
| return reg.load_config() | |
| async def save_config(config: dict = Body(...)): | |
| # 移除 _comment 字段保护 | |
| config.pop("_comment", None) | |
| # 保护 auth 配置:若前端未带 auth,则沿用旧值,避免误清空 | |
| old_cfg = reg.load_config() | |
| if "auth" not in config and isinstance(old_cfg.get("auth"), dict): | |
| config["auth"] = old_cfg["auth"] | |
| ok = reg.save_config(config) | |
| if not ok: | |
| raise HTTPException(status_code=500, detail="保存失败") | |
| # 如果守护进程已启用,同步更新其运行时配置(下次周期生效) | |
| pool = config.get("pool", {}) | |
| if _pool_daemon["enabled"] and pool: | |
| _pool_daemon["config"].update({ | |
| "base_url": pool.get("base_url", _pool_daemon["config"].get("base_url", "")), | |
| "token": pool.get("token", _pool_daemon["config"].get("token", "")), | |
| "target_type": pool.get("target_type", _pool_daemon["config"].get("target_type", "codex")), | |
| "target_count": int(pool.get("target_count", _pool_daemon["config"].get("target_count", 10))), | |
| "proxy": pool.get("proxy", _pool_daemon["config"].get("proxy", "")), | |
| "use_proxy": bool(pool.get("use_proxy", _pool_daemon["config"].get("use_proxy", True))), | |
| }) | |
| _pool_daemon["interval_min"] = max(1, int(pool.get("interval_min", _pool_daemon["interval_min"]))) | |
| return {"ok": True} | |
| # ============================================================ | |
| # 注册任务 API | |
| # ============================================================ | |
| async def register_start(body: dict = Body(...)): | |
| if _reg_state["running"]: | |
| raise HTTPException(status_code=409, detail="已有注册任务运行中") | |
| config = reg.load_config() | |
| count = int(body.get("count", 1)) | |
| workers = int(body.get("workers", config.get("workers", 3))) | |
| proxy = str(body.get("proxy", "")).strip() | |
| # 重置状态 | |
| _reg_state.update({ | |
| "running": True, | |
| "success": 0, | |
| "fail": 0, | |
| "total": count, | |
| "start_time": time.time(), | |
| }) | |
| stop_event = threading.Event() | |
| _reg_state["stop_event"] = stop_event | |
| log_cb = _make_reg_log_cb() | |
| progress_cb = _make_reg_progress_cb() | |
| def run_task(): | |
| try: | |
| result = reg.run_batch_register( | |
| count=count, | |
| workers=workers, | |
| proxy=proxy, | |
| stop_event=stop_event, | |
| log_cb=log_cb, | |
| progress_cb=progress_cb, | |
| config=config, | |
| ) | |
| _reg_state.update(result) | |
| except Exception as e: | |
| log_cb(f"[ERROR] 注册任务异常: {e}") | |
| finally: | |
| _reg_state["running"] = False | |
| _reg_state["stop_event"] = None | |
| log_cb("[注册] 任务结束") | |
| thread = threading.Thread(target=run_task, daemon=True) | |
| thread.start() | |
| return {"ok": True, "count": count, "workers": workers} | |
| async def register_stop(): | |
| stop_event = _reg_state.get("stop_event") | |
| if stop_event: | |
| stop_event.set() | |
| _reg_state["running"] = False | |
| return {"ok": True} | |
| async def register_status(): | |
| elapsed = None | |
| if _reg_state.get("start_time"): | |
| elapsed = int(time.time() - _reg_state["start_time"]) | |
| return { | |
| "running": _reg_state["running"], | |
| "success": _reg_state["success"], | |
| "fail": _reg_state["fail"], | |
| "total": _reg_state["total"], | |
| "elapsed": elapsed, | |
| } | |
| # ============================================================ | |
| # 注册日志 WebSocket | |
| # ============================================================ | |
| async def ws_register_logs(ws: WebSocket): | |
| if not _ws_current_user(ws): | |
| await ws.close(code=1008) | |
| return | |
| await ws.accept() | |
| _reg_ws_clients.append(ws) | |
| try: | |
| while True: | |
| # 从队列取日志并推送 | |
| try: | |
| msg = await asyncio.wait_for(_reg_log_queue.get(), timeout=1.0) | |
| for client in list(_reg_ws_clients): | |
| try: | |
| await client.send_text(json.dumps({"type": "log", "msg": msg})) | |
| except Exception: | |
| _reg_ws_clients.discard(client) if hasattr(_reg_ws_clients, 'discard') else None | |
| except asyncio.TimeoutError: | |
| # 发送心跳 | |
| try: | |
| await ws.send_text(json.dumps({"type": "ping"})) | |
| except Exception: | |
| break | |
| except WebSocketDisconnect: | |
| pass | |
| finally: | |
| if ws in _reg_ws_clients: | |
| _reg_ws_clients.remove(ws) | |
| # ============================================================ | |
| # 账号池 API | |
| # ============================================================ | |
| async def pool_probe(body: dict = Body(...)): | |
| if _pool_state["running"]: | |
| raise HTTPException(status_code=409, detail="已有池任务运行中") | |
| base_url = body.get("base_url", "").strip() | |
| token = body.get("token", "").strip() | |
| target_type = body.get("target_type", "codex") | |
| proxy = body.get("proxy", "").strip() | |
| use_proxy = bool(body.get("use_proxy", True)) | |
| effective_proxy = proxy if use_proxy else "" | |
| if not base_url or not token: | |
| raise HTTPException(status_code=400, detail="base_url 和 token 不能为空") | |
| _pool_state["running"] = True | |
| _pool_state["task"] = "probe" | |
| log_cb = _make_pool_log_cb() | |
| def run_task(): | |
| try: | |
| result = reg.run_pool_probe(base_url, token, target_type, effective_proxy, log_cb=log_cb) | |
| log_cb(f"[Pool] 探测结果: 总={result.get('total')}, 目标={result.get('target')}, 401={result.get('invalid_count')}") | |
| except Exception as e: | |
| log_cb(f"[ERROR] 探测异常: {e}") | |
| finally: | |
| _pool_state["running"] = False | |
| threading.Thread(target=run_task, daemon=True).start() | |
| return {"ok": True, "task": "probe"} | |
| async def pool_clean(body: dict = Body(...)): | |
| if _pool_state["running"]: | |
| raise HTTPException(status_code=409, detail="已有池任务运行中") | |
| base_url = body.get("base_url", "").strip() | |
| token = body.get("token", "").strip() | |
| target_type = body.get("target_type", "codex") | |
| proxy = body.get("proxy", "").strip() | |
| use_proxy = bool(body.get("use_proxy", True)) | |
| effective_proxy = proxy if use_proxy else "" | |
| probe_result = body.get("probe_result") | |
| probe_signature = str(body.get("probe_signature", "") or "") | |
| probe_ts_raw = body.get("probe_ts") | |
| if not base_url or not token: | |
| raise HTTPException(status_code=400, detail="base_url 和 token 不能为空") | |
| _pool_state["running"] = True | |
| _pool_state["task"] = "clean" | |
| log_cb = _make_pool_log_cb() | |
| def run_task(): | |
| try: | |
| now_ts = int(time.time()) | |
| expected_signature = _build_probe_signature(base_url, token, target_type, effective_proxy) | |
| use_cached_probe = False | |
| if isinstance(probe_result, dict): | |
| try: | |
| probe_ts = int(probe_ts_raw) | |
| except Exception: | |
| probe_ts = 0 | |
| is_fresh = probe_ts > 0 and (now_ts - probe_ts) <= _PROBE_RESULT_TTL_SEC | |
| signature_ok = bool(probe_signature) and probe_signature == expected_signature | |
| invalid_list_ok = isinstance(probe_result.get("invalid_401"), list) | |
| use_cached_probe = is_fresh and signature_ok and invalid_list_ok | |
| if use_cached_probe: | |
| log_cb("[Pool] 复用检查结果执行清理,跳过重复探测") | |
| result = reg.run_pool_clean_with_probe_result( | |
| base_url=base_url, | |
| token=token, | |
| probe_result=probe_result, | |
| proxy=effective_proxy, | |
| log_cb=log_cb, | |
| ) | |
| else: | |
| if isinstance(probe_result, dict): | |
| log_cb("[Pool] 检查结果不可用或已过期,回退为重新探测后清理") | |
| result = reg.run_pool_clean(base_url, token, target_type, effective_proxy, log_cb=log_cb) | |
| log_cb(f"[Pool] 清理完成: 删除={result.get('deleted')}, 失败={result.get('delete_fail')}") | |
| except Exception as e: | |
| log_cb(f"[ERROR] 清理异常: {e}") | |
| finally: | |
| _pool_state["running"] = False | |
| threading.Thread(target=run_task, daemon=True).start() | |
| return {"ok": True, "task": "clean"} | |
| async def pool_fill(body: dict = Body(...)): | |
| if _pool_state["running"]: | |
| raise HTTPException(status_code=409, detail="已有池任务运行中") | |
| count = int(body.get("count", 1)) | |
| base_url = body.get("base_url", "").strip() | |
| pool_token = body.get("token", "").strip() | |
| proxy = body.get("proxy", "").strip() | |
| use_proxy = bool(body.get("use_proxy", True)) | |
| effective_proxy = proxy if use_proxy else "" | |
| target_type = body.get("target_type", "codex") | |
| target_count = int(body.get("target_count", 0)) | |
| config = reg.load_config() | |
| _pool_state["running"] = True | |
| _pool_state["task"] = "fill" | |
| log_cb = _make_pool_log_cb() | |
| stop_event = threading.Event() | |
| _pool_state["stop_event"] = stop_event | |
| def progress_cb(s, f, t): | |
| pass | |
| def run_task(): | |
| try: | |
| result = reg.run_pool_fill( | |
| fill_count=count, | |
| base_url=base_url, | |
| pool_token=pool_token, | |
| stop_event=stop_event, | |
| log_cb=log_cb, | |
| progress_cb=progress_cb, | |
| config=config, | |
| proxy=effective_proxy, | |
| target_count=target_count, | |
| target_type=target_type, | |
| ) | |
| log_cb(f"[Pool] 补号完成: 成功={result.get('success')}, 失败={result.get('fail')}") | |
| except Exception as e: | |
| log_cb(f"[ERROR] 补号异常: {e}") | |
| finally: | |
| _pool_state["running"] = False | |
| _pool_state.pop("stop_event", None) | |
| threading.Thread(target=run_task, daemon=True).start() | |
| return {"ok": True, "task": "fill", "count": count} | |
| async def pool_overview_api(body: dict = Body(...)): | |
| base_url = body.get("base_url", "").strip() | |
| token = body.get("token", "").strip() | |
| target_type = body.get("target_type", "codex") | |
| proxy = body.get("proxy", "").strip() | |
| use_proxy = bool(body.get("use_proxy", True)) | |
| effective_proxy = proxy if use_proxy else "" | |
| if not base_url or not token: | |
| raise HTTPException(status_code=400, detail="base_url 和 token 不能为空") | |
| result = await asyncio.get_event_loop().run_in_executor( | |
| None, lambda: reg.get_pool_overview(base_url, token, target_type, effective_proxy) | |
| ) | |
| return result | |
| async def pool_accounts(base_url: str, token: str, | |
| target_type: str = "codex", proxy: str = "", use_proxy: bool = True): | |
| if not base_url or not token: | |
| raise HTTPException(status_code=400, detail="base_url 和 token 不能为空") | |
| effective_proxy = proxy if use_proxy else "" | |
| result = await asyncio.get_event_loop().run_in_executor( | |
| None, lambda: reg.get_pool_accounts(base_url, token, target_type, effective_proxy) | |
| ) | |
| return result | |
| async def pool_sync(body: dict = Body(...)): | |
| base_url = body.get("base_url", "").strip() | |
| token = body.get("token", "").strip() | |
| target_type = body.get("target_type", "codex") | |
| proxy = body.get("proxy", "").strip() | |
| use_proxy = bool(body.get("use_proxy", True)) | |
| effective_proxy = proxy if use_proxy else "" | |
| target_count = int(body.get("target_count", 0)) | |
| if not base_url or not token: | |
| raise HTTPException(status_code=400, detail="base_url 和 token 不能为空") | |
| log_cb = _make_pool_log_cb() | |
| result = await asyncio.get_event_loop().run_in_executor( | |
| None, lambda: reg.sync_local_remote(base_url, token, target_type, proxy=effective_proxy, log_cb=log_cb, target_count=target_count) | |
| ) | |
| return result | |
| async def pool_task_status(): | |
| return { | |
| "running": _pool_state["running"], | |
| "task": _pool_state.get("task", ""), | |
| } | |
| async def pool_inspect(body: dict = Body(...)): | |
| """同步探测账号池,返回检查结果(供前端渲染确认卡)""" | |
| base_url = body.get("base_url", "").strip() | |
| token = body.get("token", "").strip() | |
| target_type = body.get("target_type", "codex") | |
| target_count = int(body.get("target_count", 100)) | |
| proxy = body.get("proxy", "").strip() | |
| use_proxy = bool(body.get("use_proxy", True)) | |
| effective_proxy = proxy if use_proxy else "" | |
| if not base_url or not token: | |
| raise HTTPException(status_code=400, detail="base_url 和 token 不能为空") | |
| log_cb = _make_pool_log_cb() | |
| log_cb("[Pool] 开始检查失效账号...") | |
| loop = asyncio.get_event_loop() | |
| result = await loop.run_in_executor( | |
| None, | |
| lambda: reg.run_pool_probe(base_url, token, target_type, effective_proxy, log_cb=log_cb), | |
| ) | |
| if not result.get("ok"): | |
| raise HTTPException(status_code=500, detail=result.get("error", "探测失败")) | |
| log_cb(f"[Pool] 检查结束: 总={result.get('total')}, 目标={result.get('target')}, 401={result.get('invalid_count')}") | |
| probe_ts = int(time.time()) | |
| probe_signature = _build_probe_signature(base_url, token, target_type, effective_proxy) | |
| valid = result["target"] - result["invalid_count"] | |
| gap = max(0, target_count - valid) | |
| return { | |
| **result, | |
| "valid_count": valid, | |
| "gap": gap, | |
| "target_count": target_count, | |
| "probe_ts": probe_ts, | |
| "probe_signature": probe_signature, | |
| } | |
| # ============================================================ | |
| # 池管理日志 WebSocket | |
| # ============================================================ | |
| async def ws_pool_logs(ws: WebSocket): | |
| if not _ws_current_user(ws): | |
| await ws.close(code=1008) | |
| return | |
| await ws.accept() | |
| _pool_ws_clients.append(ws) | |
| try: | |
| while True: | |
| try: | |
| msg = await asyncio.wait_for(_pool_log_queue.get(), timeout=1.0) | |
| for client in list(_pool_ws_clients): | |
| try: | |
| await client.send_text(json.dumps({"type": "log", "msg": msg})) | |
| except Exception: | |
| pass | |
| except asyncio.TimeoutError: | |
| try: | |
| await ws.send_text(json.dumps({"type": "ping"})) | |
| except Exception: | |
| break | |
| except WebSocketDisconnect: | |
| pass | |
| finally: | |
| if ws in _pool_ws_clients: | |
| _pool_ws_clients.remove(ws) | |
| # ============================================================ | |
| # 代理管理 API | |
| # ============================================================ | |
| async def proxy_fetch(): | |
| config = reg.load_config() | |
| fallback_proxy = config.get("proxy", "") | |
| proxies = await asyncio.get_event_loop().run_in_executor( | |
| None, lambda: reg.fetch_free_proxies(proxy=fallback_proxy) | |
| ) | |
| return {"ok": True, "proxies": proxies, "count": len(proxies)} | |
| async def proxy_test(body: dict = Body(...)): | |
| proxies = body.get("proxies", []) | |
| target_url = body.get("target_url", "https://httpbin.org/ip") | |
| timeout = int(body.get("timeout", 5)) | |
| if not proxies: | |
| raise HTTPException(status_code=400, detail="proxies 不能为空") | |
| if len(proxies) > 50: | |
| proxies = proxies[:50] | |
| results = await asyncio.get_event_loop().run_in_executor( | |
| None, | |
| lambda: reg.test_proxies_concurrent(proxies, target_url, timeout, max_workers=20), | |
| ) | |
| return {"ok": True, "results": results} | |
| # ============================================================ | |
| # 结果查看 API | |
| # ============================================================ | |
| async def get_results(): | |
| config = reg.load_config() | |
| accounts = reg.read_registered_accounts(config) | |
| return {"ok": True, "accounts": accounts, "count": len(accounts)} | |
| async def get_tokens(): | |
| config = reg.load_config() | |
| tokens = reg.list_codex_tokens(config) | |
| return {"ok": True, "tokens": tokens, "count": len(tokens)} | |
| async def get_ak(): | |
| config = reg.load_config() | |
| content = reg.read_token_file("ak.txt", config) | |
| return PlainTextResponse(content=content) | |
| async def get_rk(): | |
| config = reg.load_config() | |
| content = reg.read_token_file("rk.txt", config) | |
| return PlainTextResponse(content=content) | |
| async def download_ak(): | |
| config = reg.load_config() | |
| ak_file = config.get("ak_file", "ak.txt") | |
| if not os.path.isabs(ak_file): | |
| ak_file = str(_BASE_DIR / ak_file) | |
| if not os.path.exists(ak_file): | |
| raise HTTPException(status_code=404, detail="ak.txt 不存在") | |
| return FileResponse(ak_file, filename="ak.txt", media_type="text/plain") | |
| async def download_rk(): | |
| config = reg.load_config() | |
| rk_file = config.get("rk_file", "rk.txt") | |
| if not os.path.isabs(rk_file): | |
| rk_file = str(_BASE_DIR / rk_file) | |
| if not os.path.exists(rk_file): | |
| raise HTTPException(status_code=404, detail="rk.txt 不存在") | |
| return FileResponse(rk_file, filename="rk.txt", media_type="text/plain") | |
| # ============================================================ | |
| # 号池守护进程 | |
| # ============================================================ | |
| _pool_daemon: Dict[str, Any] = { | |
| "enabled": False, | |
| "interval_min": 30, | |
| "next_run_ts": None, | |
| "last_run_ts": None, | |
| "running_now": False, | |
| "config": {}, # {base_url, token, target_type, target_count, proxy} | |
| } | |
| _pool_daemon_timer: Optional[threading.Timer] = None | |
| def _run_daemon_once(): | |
| """守护进程单次执行""" | |
| global _pool_daemon_timer | |
| if not _pool_daemon["enabled"]: | |
| return | |
| _pool_daemon["running_now"] = True | |
| log_cb = _make_pool_log_cb() | |
| try: | |
| cfg = _pool_daemon["config"] | |
| proxy = reg._proxy_pool.get_best(cfg.get("proxy", "")) if cfg.get("use_proxy", True) else "" | |
| stop_event = threading.Event() | |
| reg.run_pool_maintain_cycle( | |
| base_url=cfg.get("base_url", ""), | |
| token=cfg.get("token", ""), | |
| target_type=cfg.get("target_type", "codex"), | |
| target_count=int(cfg.get("target_count", 10)), | |
| stop_event=stop_event, | |
| log_cb=log_cb, | |
| config=reg.load_config(), | |
| proxy=proxy, | |
| ) | |
| except Exception as e: | |
| log_cb(f"[Daemon] 执行异常: {e}") | |
| finally: | |
| _pool_daemon["running_now"] = False | |
| _pool_daemon["last_run_ts"] = time.time() | |
| if _pool_daemon["enabled"]: | |
| interval_sec = _pool_daemon["interval_min"] * 60 | |
| _pool_daemon["next_run_ts"] = time.time() + interval_sec | |
| _pool_daemon_timer = threading.Timer(interval_sec, _run_daemon_once) | |
| _pool_daemon_timer.daemon = True | |
| _pool_daemon_timer.start() | |
| async def pool_daemon_start(body: dict = Body(...)): | |
| global _pool_daemon_timer | |
| base_url = body.get("base_url", "").strip() | |
| token = body.get("token", "").strip() | |
| if not base_url or not token: | |
| raise HTTPException(status_code=400, detail="base_url 和 token 不能为空") | |
| # 停止旧 timer | |
| if _pool_daemon_timer and _pool_daemon_timer.is_alive(): | |
| _pool_daemon_timer.cancel() | |
| interval_min = max(1, int(body.get("interval_min", 30))) | |
| _pool_daemon.update({ | |
| "enabled": True, | |
| "interval_min": interval_min, | |
| "config": { | |
| "base_url": base_url, | |
| "token": token, | |
| "target_type": body.get("target_type", "codex"), | |
| "target_count": int(body.get("target_count", 10)), | |
| "proxy": body.get("proxy", "").strip(), | |
| "use_proxy": bool(body.get("use_proxy", True)), | |
| }, | |
| }) | |
| # 立即执行一次(在后台线程) | |
| _pool_daemon["next_run_ts"] = None | |
| t = threading.Thread(target=_run_daemon_once, daemon=True) | |
| t.start() | |
| return {"ok": True, "interval_min": interval_min} | |
| async def pool_daemon_stop(): | |
| global _pool_daemon_timer | |
| _pool_daemon["enabled"] = False | |
| _pool_daemon["next_run_ts"] = None | |
| if _pool_daemon_timer and _pool_daemon_timer.is_alive(): | |
| _pool_daemon_timer.cancel() | |
| _pool_daemon_timer = None | |
| return {"ok": True} | |
| async def pool_daemon_status(): | |
| remaining = None | |
| if _pool_daemon["next_run_ts"]: | |
| remaining = max(0, int(_pool_daemon["next_run_ts"] - time.time())) | |
| return { | |
| "enabled": _pool_daemon["enabled"], | |
| "running_now": _pool_daemon["running_now"], | |
| "interval_min": _pool_daemon["interval_min"], | |
| "next_run_ts": _pool_daemon["next_run_ts"], | |
| "last_run_ts": _pool_daemon["last_run_ts"], | |
| "remaining_sec": remaining, | |
| "config": _pool_daemon["config"], | |
| } | |
| async def pool_daemon_run_once(body: dict = Body(default={})): | |
| """立即触发一次维护周期(不影响守护进程定时器)""" | |
| cfg = _pool_daemon["config"] | |
| # 允许临时覆盖配置 | |
| if body.get("base_url"): | |
| cfg = {**cfg, **body} | |
| base_url = cfg.get("base_url", "").strip() | |
| token = cfg.get("token", "").strip() | |
| if not base_url or not token: | |
| raise HTTPException(status_code=400, detail="请先配置 base_url 和 token") | |
| if _pool_daemon["running_now"]: | |
| raise HTTPException(status_code=409, detail="守护进程正在运行中") | |
| log_cb = _make_pool_log_cb() | |
| def run_task(): | |
| _pool_daemon["running_now"] = True | |
| try: | |
| proxy = reg._proxy_pool.get_best(cfg.get("proxy", "")) if cfg.get("use_proxy", True) else "" | |
| stop_event = threading.Event() | |
| reg.run_pool_maintain_cycle( | |
| base_url=cfg.get("base_url", ""), | |
| token=cfg.get("token", ""), | |
| target_type=cfg.get("target_type", "codex"), | |
| target_count=int(cfg.get("target_count", 10)), | |
| stop_event=stop_event, | |
| log_cb=log_cb, | |
| config=reg.load_config(), | |
| proxy=proxy, | |
| ) | |
| except Exception as e: | |
| log_cb(f"[Daemon] 执行异常: {e}") | |
| finally: | |
| _pool_daemon["running_now"] = False | |
| _pool_daemon["last_run_ts"] = time.time() | |
| threading.Thread(target=run_task, daemon=True).start() | |
| return {"ok": True} | |
| # ============================================================ | |
| # 代理池端点 | |
| # ============================================================ | |
| async def proxy_pool_update(body: dict = Body(...)): | |
| """接收前端测试结果,更新内存代理池""" | |
| results = body.get("results", []) | |
| if not isinstance(results, list): | |
| raise HTTPException(status_code=400, detail="results 必须是列表") | |
| reg._proxy_pool.update(results) | |
| best = reg._proxy_pool.get_best() | |
| return {"ok": True, "count": len(results), "best": best} | |
| async def proxy_active(): | |
| """返回当前最优代理""" | |
| config = reg.load_config() | |
| fallback = config.get("proxy", "") | |
| best = reg._proxy_pool.get_best(fallback) | |
| source = "free_pool" if reg._proxy_pool.get_best() else ("user_config" if fallback else "direct") | |
| return { | |
| "proxy": best, | |
| "source": source, | |
| "pool_size": len(reg._proxy_pool.get_all()), | |
| } | |
| async def proxy_active(): | |
| return { | |
| "healthyz": "ok" | |
| } | |
| # ============================================================ | |
| # 主入口 | |
| # ============================================================ | |
| if __name__ == "__main__": | |
| uvicorn.run( | |
| "web_app:app", | |
| host="0.0.0.0", | |
| port=8080, | |
| reload=False, | |
| log_level="info", | |
| ) | |