my-test / web_app.py
Looknicemm's picture
Update web_app.py
7598539 verified
Raw
History Blame Contribute Delete
37.7 kB
"""
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="/")
@app.on_event("startup")
async def _startup():
global _event_loop, _runtime_jwt_secret
_event_loop = asyncio.get_event_loop()
_runtime_jwt_secret = secrets.token_urlsafe(48)
@app.middleware("http")
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
# ============================================================
# 静态文件 & 首页
# ============================================================
@app.get("/", response_class=HTMLResponse)
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"))
@app.post("/api/auth/login")
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
@app.post("/api/auth/setup")
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
@app.post("/api/auth/logout")
async def auth_logout():
resp = JSONResponse({"ok": True})
_clear_auth_cookie(resp)
return resp
@app.get("/api/auth/me")
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
# ============================================================
@app.get("/api/config")
async def get_config():
return reg.load_config()
@app.post("/api/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
# ============================================================
@app.post("/api/register/start")
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}
@app.post("/api/register/stop")
async def register_stop():
stop_event = _reg_state.get("stop_event")
if stop_event:
stop_event.set()
_reg_state["running"] = False
return {"ok": True}
@app.get("/api/register/status")
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
# ============================================================
@app.websocket("/ws/register/logs")
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
# ============================================================
@app.post("/api/pool/probe")
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"}
@app.post("/api/pool/clean")
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"}
@app.post("/api/pool/fill")
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}
@app.post("/api/pool/overview")
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
@app.get("/api/pool/accounts")
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
@app.post("/api/pool/sync")
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
@app.get("/api/pool/task-status")
async def pool_task_status():
return {
"running": _pool_state["running"],
"task": _pool_state.get("task", ""),
}
@app.post("/api/pool/inspect")
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
# ============================================================
@app.websocket("/ws/pool/logs")
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
# ============================================================
@app.get("/api/proxy/fetch")
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)}
@app.post("/api/proxy/test")
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
# ============================================================
@app.get("/api/results")
async def get_results():
config = reg.load_config()
accounts = reg.read_registered_accounts(config)
return {"ok": True, "accounts": accounts, "count": len(accounts)}
@app.get("/api/tokens")
async def get_tokens():
config = reg.load_config()
tokens = reg.list_codex_tokens(config)
return {"ok": True, "tokens": tokens, "count": len(tokens)}
@app.get("/api/tokens/ak")
async def get_ak():
config = reg.load_config()
content = reg.read_token_file("ak.txt", config)
return PlainTextResponse(content=content)
@app.get("/api/tokens/rk")
async def get_rk():
config = reg.load_config()
content = reg.read_token_file("rk.txt", config)
return PlainTextResponse(content=content)
@app.get("/api/tokens/download/ak")
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")
@app.get("/api/tokens/download/rk")
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()
@app.post("/api/pool/daemon/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}
@app.post("/api/pool/daemon/stop")
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}
@app.get("/api/pool/daemon/status")
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"],
}
@app.post("/api/pool/daemon/run-once")
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}
# ============================================================
# 代理池端点
# ============================================================
@app.post("/api/proxy/pool/update")
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}
@app.get("/api/proxy/active")
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()),
}
@app.get("/healthyz")
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",
)