feat: fpw plugins, validation/crawl perf, WS stats, test DB isolation

- Add Free_Proxy_Website-style fpw_* plugins and register them
- Per-plugin crawl timeout (crawl_timeout_seconds=120); remove global crawl_timeout setting
- Validator: fix connect vs total timeout on save; SOCKS session LRU cache; drop redundant semaphore
- Validation handler uses single DB connection; batch upsert after crawl; WorkerPool put_nowait
- Remove unused max_retries from settings API/UI; settings maintenance SQL + init_db cleanup of deprecated keys
- WebSocket dashboard stats; ProxyList pool_filter and API alignment
- POST /api/proxies/delete-one for IPv6-safe deletes; task poll stops on 404
- pytest uses PROXYPOOL_DB_PATH=db/proxies.test.sqlite so tests do not wipe production DB
- .gitignore: explicit proxies.test.sqlite patterns; fix plugin_service ValidationException import

Made-with: Cursor
This commit is contained in:
祀梦
2026-04-05 13:39:19 +08:00
parent 92c7fa19e2
commit 0131c8b408
63 changed files with 2331 additions and 531 deletions

52
app/api/ws_manager.py Normal file
View File

@@ -0,0 +1,52 @@
"""WebSocket 连接管理与广播"""
import asyncio
from typing import List
from starlette.websockets import WebSocket, WebSocketState
class ConnectionManager:
def __init__(self) -> None:
self._connections: List[WebSocket] = []
self._lock = asyncio.Lock()
@property
def connection_count(self) -> int:
return len(self._connections)
async def connect(self, websocket: WebSocket) -> None:
async with self._lock:
self._connections.append(websocket)
async def disconnect(self, websocket: WebSocket) -> None:
async with self._lock:
if websocket in self._connections:
self._connections.remove(websocket)
async def broadcast_json(self, payload: dict) -> None:
async with self._lock:
targets = list(self._connections)
stale: List[WebSocket] = []
for ws in targets:
try:
if ws.client_state != WebSocketState.CONNECTED:
stale.append(ws)
continue
await ws.send_json(payload)
except Exception:
stale.append(ws)
if stale:
async with self._lock:
for ws in stale:
if ws in self._connections:
self._connections.remove(ws)
async def disconnect_all(self) -> None:
async with self._lock:
targets = list(self._connections)
self._connections.clear()
for ws in targets:
try:
await ws.close()
except Exception:
pass