移除HTTP详情请求日志
This commit is contained in:
@@ -22,7 +22,6 @@ from core.geetest.common.network import (
|
|||||||
get_c_s,
|
get_c_s,
|
||||||
req_fullpage_validate,
|
req_fullpage_validate,
|
||||||
)
|
)
|
||||||
from utils.http_logger import log_http
|
|
||||||
|
|
||||||
|
|
||||||
class AccountLike(Protocol):
|
class AccountLike(Protocol):
|
||||||
@@ -246,14 +245,9 @@ class DouyuLogin:
|
|||||||
"""
|
"""
|
||||||
统一发送请求,附带分段超时和更明确的错误信息。
|
统一发送请求,附带分段超时和更明确的错误信息。
|
||||||
代理连接失败时自动重试获取新的代理IP。
|
代理连接失败时自动重试获取新的代理IP。
|
||||||
所有请求详情会记录到 HTTP 详情日志。
|
|
||||||
"""
|
"""
|
||||||
timeout = kwargs.pop('timeout', self.timeout)
|
timeout = kwargs.pop('timeout', self.timeout)
|
||||||
safe_url = self._safe_url(url)
|
safe_url = self._safe_url(url)
|
||||||
req_data = kwargs.get('data')
|
|
||||||
req_body = req_data if req_data else kwargs.get('json')
|
|
||||||
current_proxy = self._current_proxy_url
|
|
||||||
tag = self.account.username if self.account else ""
|
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
self._ensure_not_stopped()
|
self._ensure_not_stopped()
|
||||||
@@ -266,30 +260,9 @@ class DouyuLogin:
|
|||||||
f"{method.upper()} {safe_url} -> {response.status_code} "
|
f"{method.upper()} {safe_url} -> {response.status_code} "
|
||||||
f"({elapsed:.2f}s)"
|
f"({elapsed:.2f}s)"
|
||||||
)
|
)
|
||||||
# 记录请求/响应详情
|
|
||||||
resp_body = response.text[:500] if response.text else ""
|
|
||||||
log_http(
|
|
||||||
category="douyu_login",
|
|
||||||
method=method,
|
|
||||||
url=safe_url,
|
|
||||||
request_headers=dict(self.session.headers),
|
|
||||||
request_body=req_body,
|
|
||||||
status_code=response.status_code,
|
|
||||||
response_headers=dict(response.headers),
|
|
||||||
response_body=resp_body,
|
|
||||||
duration=elapsed,
|
|
||||||
proxy=current_proxy,
|
|
||||||
tag=tag,
|
|
||||||
)
|
|
||||||
return response
|
return response
|
||||||
except requests.Timeout as exc:
|
except requests.Timeout as exc:
|
||||||
elapsed = time.monotonic() - started
|
elapsed = time.monotonic() - started
|
||||||
log_http(
|
|
||||||
category="douyu_login", method=method, url=safe_url,
|
|
||||||
request_headers=dict(self.session.headers), request_body=req_body,
|
|
||||||
duration=elapsed, error=f"超时 timeout={timeout}: {exc}",
|
|
||||||
proxy=current_proxy, tag=tag,
|
|
||||||
)
|
|
||||||
raise TimeoutError(
|
raise TimeoutError(
|
||||||
f"{method.upper()} {safe_url} 超时,耗时 {elapsed:.1f}s,"
|
f"{method.upper()} {safe_url} 超时,耗时 {elapsed:.1f}s,"
|
||||||
f"timeout={timeout}"
|
f"timeout={timeout}"
|
||||||
@@ -298,18 +271,10 @@ class DouyuLogin:
|
|||||||
elapsed = time.monotonic() - started
|
elapsed = time.monotonic() - started
|
||||||
err_str = str(exc)
|
err_str = str(exc)
|
||||||
is_proxy_err = "proxy" in err_str.lower() or "Proxy" in type(exc).__name__
|
is_proxy_err = "proxy" in err_str.lower() or "Proxy" in type(exc).__name__
|
||||||
log_http(
|
|
||||||
category="douyu_login", method=method, url=safe_url,
|
|
||||||
request_headers=dict(self.session.headers), request_body=req_body,
|
|
||||||
duration=elapsed,
|
|
||||||
error=f"{'代理' if is_proxy_err else ''}连接失败: {err_str[:300]}",
|
|
||||||
proxy=current_proxy, tag=tag,
|
|
||||||
)
|
|
||||||
if is_proxy_err:
|
if is_proxy_err:
|
||||||
logger.warning(f"代理连接失败,尝试 {attempt + 1}/{max_retries}: {exc}")
|
logger.warning(f"代理连接失败,尝试 {attempt + 1}/{max_retries}: {exc}")
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
self._refresh_proxy()
|
self._refresh_proxy()
|
||||||
current_proxy = self._current_proxy_url
|
|
||||||
self._sleep_interruptible(1)
|
self._sleep_interruptible(1)
|
||||||
continue
|
continue
|
||||||
raise ConnectionError(
|
raise ConnectionError(
|
||||||
@@ -325,12 +290,6 @@ class DouyuLogin:
|
|||||||
) from exc
|
) from exc
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
elapsed = time.monotonic() - started
|
elapsed = time.monotonic() - started
|
||||||
log_http(
|
|
||||||
category="douyu_login", method=method, url=safe_url,
|
|
||||||
request_headers=dict(self.session.headers), request_body=req_body,
|
|
||||||
duration=elapsed, error=f"请求异常: {exc}",
|
|
||||||
proxy=current_proxy, tag=tag,
|
|
||||||
)
|
|
||||||
raise ConnectionError(
|
raise ConnectionError(
|
||||||
f"{method.upper()} {safe_url} 请求失败,耗时 {elapsed:.1f}s: {exc}"
|
f"{method.upper()} {safe_url} 请求失败,耗时 {elapsed:.1f}s: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import time as _time
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -19,12 +18,9 @@ def verify_proxy_url(proxy_url: str, timeout: tuple = (5, 8)) -> tuple[bool, str
|
|||||||
Returns:
|
Returns:
|
||||||
(是否可用, 消息)
|
(是否可用, 消息)
|
||||||
"""
|
"""
|
||||||
from utils.http_logger import log_http
|
|
||||||
|
|
||||||
proxies = {'http': proxy_url, 'https': proxy_url}
|
proxies = {'http': proxy_url, 'https': proxy_url}
|
||||||
|
|
||||||
# ── 第1关:斗鱼主站 ──
|
# ── 第1关:斗鱼主站 ──
|
||||||
started = _time.monotonic()
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
'https://www.douyu.com',
|
'https://www.douyu.com',
|
||||||
@@ -32,15 +28,8 @@ def verify_proxy_url(proxy_url: str, timeout: tuple = (5, 8)) -> tuple[bool, str
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
headers={'User-Agent': 'Mozilla/5.0'},
|
headers={'User-Agent': 'Mozilla/5.0'},
|
||||||
)
|
)
|
||||||
elapsed = _time.monotonic() - started
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
log_http(
|
|
||||||
category="proxy_verify", method="GET", url="https://www.douyu.com",
|
|
||||||
status_code=response.status_code, response_body=f"斗鱼主站可达: {proxy_url}",
|
|
||||||
duration=elapsed, proxy=proxy_url, tag="proxy_verify",
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
elapsed = _time.monotonic() - started
|
|
||||||
err_msg = str(exc)
|
err_msg = str(exc)
|
||||||
if 'Tunnel connection failed' in err_msg or '503' in err_msg:
|
if 'Tunnel connection failed' in err_msg or '503' in err_msg:
|
||||||
detail = '代理拒绝连接(白名单可能未生效)'
|
detail = '代理拒绝连接(白名单可能未生效)'
|
||||||
@@ -49,15 +38,9 @@ def verify_proxy_url(proxy_url: str, timeout: tuple = (5, 8)) -> tuple[bool, str
|
|||||||
else:
|
else:
|
||||||
detail = type(exc).__name__
|
detail = type(exc).__name__
|
||||||
logger.debug(f"代理验证失败 [{proxy_url}]: 斗鱼主站不可达: {detail}")
|
logger.debug(f"代理验证失败 [{proxy_url}]: 斗鱼主站不可达: {detail}")
|
||||||
log_http(
|
|
||||||
category="proxy_verify", method="GET", url="https://www.douyu.com",
|
|
||||||
duration=elapsed, error=f"[{proxy_url}] {detail}: {err_msg[:200]}",
|
|
||||||
proxy=proxy_url, tag="proxy_verify",
|
|
||||||
)
|
|
||||||
return False, detail
|
return False, detail
|
||||||
|
|
||||||
# ── 第2关:极验接口 ──
|
# ── 第2关:极验接口 ──
|
||||||
started = _time.monotonic()
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
'https://api.geetest.com',
|
'https://api.geetest.com',
|
||||||
@@ -67,26 +50,14 @@ def verify_proxy_url(proxy_url: str, timeout: tuple = (5, 8)) -> tuple[bool, str
|
|||||||
# geetest 首页可能返回 4xx,只要能连上就算通
|
# geetest 首页可能返回 4xx,只要能连上就算通
|
||||||
allow_redirects=True,
|
allow_redirects=True,
|
||||||
)
|
)
|
||||||
elapsed = _time.monotonic() - started
|
|
||||||
log_http(
|
|
||||||
category="proxy_verify", method="GET", url="https://api.geetest.com",
|
|
||||||
status_code=response.status_code, response_body=f"代理可用: {proxy_url}",
|
|
||||||
duration=elapsed, proxy=proxy_url, tag="proxy_verify",
|
|
||||||
)
|
|
||||||
return True, '代理可用 → 斗鱼+极验'
|
return True, '代理可用 → 斗鱼+极验'
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
elapsed = _time.monotonic() - started
|
|
||||||
err_msg = str(exc)
|
err_msg = str(exc)
|
||||||
if 'timed out' in err_msg.lower():
|
if 'timed out' in err_msg.lower():
|
||||||
detail = '极验接口超时'
|
detail = '极验接口超时'
|
||||||
else:
|
else:
|
||||||
detail = f'极验不可达: {type(exc).__name__}'
|
detail = f'极验不可达: {type(exc).__name__}'
|
||||||
logger.debug(f"代理验证失败 [{proxy_url}]: 斗鱼可达但{detail}")
|
logger.debug(f"代理验证失败 [{proxy_url}]: 斗鱼可达但{detail}")
|
||||||
log_http(
|
|
||||||
category="proxy_verify", method="GET", url="https://api.geetest.com",
|
|
||||||
duration=elapsed, error=f"[{proxy_url}] {detail}: {err_msg[:200]}",
|
|
||||||
proxy=proxy_url, tag="proxy_verify",
|
|
||||||
)
|
|
||||||
return False, detail
|
return False, detail
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
"""HTTP 请求/响应详情日志记录器
|
|
||||||
|
|
||||||
将关键 HTTP 请求的完整详情(method、url、headers、body、status、response、耗时)
|
|
||||||
以 JSON Lines 格式写入日志文件,便于在 Web 界面查看和排查问题。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
# 日志文件路径
|
|
||||||
_LOG_DIR = Path("logs")
|
|
||||||
_LOG_FILE = _LOG_DIR / "http_detail.jsonl"
|
|
||||||
_MAX_BODY_LEN = 2000 # 单个 body 最大记录长度,避免过大
|
|
||||||
|
|
||||||
# 线程安全写锁
|
|
||||||
_write_lock = threading.Lock()
|
|
||||||
|
|
||||||
# 确保日志目录存在
|
|
||||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _truncate(text: Any, max_len: int = _MAX_BODY_LEN) -> str:
|
|
||||||
"""截断过长的文本"""
|
|
||||||
if text is None:
|
|
||||||
return ""
|
|
||||||
s = text if isinstance(text, str) else str(text)
|
|
||||||
if len(s) > max_len:
|
|
||||||
return s[:max_len] + f"...[truncated {len(s) - max_len} chars]"
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_headers(headers: Any) -> dict:
|
|
||||||
"""清理 headers 中的敏感信息"""
|
|
||||||
if not headers:
|
|
||||||
return {}
|
|
||||||
if hasattr(headers, 'items'):
|
|
||||||
headers = dict(headers)
|
|
||||||
safe = {}
|
|
||||||
sensitive = {'authorization', 'cookie', 'set-cookie', 'password'}
|
|
||||||
for k, v in headers.items():
|
|
||||||
if k.lower() in sensitive:
|
|
||||||
safe[k] = '***'
|
|
||||||
else:
|
|
||||||
safe[k] = v
|
|
||||||
return safe
|
|
||||||
|
|
||||||
|
|
||||||
# 请求体中需要脱敏的字段名(小写匹配,包含即遮罩)
|
|
||||||
_SENSITIVE_BODY_KEYS = {
|
|
||||||
'password', 'pwd', 'passwd', 'secret', 'token', 'apikey', 'api_key',
|
|
||||||
'email_password', 'mm', 'authorization', 'credential',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_body(body: Any) -> str:
|
|
||||||
"""清理请求体中的敏感字段值。
|
|
||||||
|
|
||||||
支持 dict、JSON 字符串、其他类型。
|
|
||||||
敏感字段的值会被替换为 ***,其余内容保留(仍受 _truncate 限制)。
|
|
||||||
"""
|
|
||||||
if body is None:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# 尝试解析 JSON 字符串
|
|
||||||
parsed = body
|
|
||||||
if isinstance(body, str):
|
|
||||||
try:
|
|
||||||
parsed = json.loads(body)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
# 非 JSON 字符串,直接截断
|
|
||||||
return _truncate(body)
|
|
||||||
|
|
||||||
# dict 类型:遮罩敏感字段
|
|
||||||
if isinstance(parsed, dict):
|
|
||||||
safe = {}
|
|
||||||
for k, v in parsed.items():
|
|
||||||
if any(s in k.lower() for s in _SENSITIVE_BODY_KEYS):
|
|
||||||
safe[k] = '***'
|
|
||||||
elif isinstance(v, (dict, list)):
|
|
||||||
safe[k] = _safe_body(v) if isinstance(v, dict) else _truncate(str(v))
|
|
||||||
else:
|
|
||||||
safe[k] = v
|
|
||||||
return _truncate(json.dumps(safe, ensure_ascii=False))
|
|
||||||
|
|
||||||
# 其他类型:直接截断
|
|
||||||
return _truncate(str(body))
|
|
||||||
|
|
||||||
|
|
||||||
def log_http(
|
|
||||||
category: str,
|
|
||||||
method: str,
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
request_headers: Any = None,
|
|
||||||
request_body: Any = None,
|
|
||||||
status_code: Optional[int] = None,
|
|
||||||
response_headers: Any = None,
|
|
||||||
response_body: Any = None,
|
|
||||||
duration: Optional[float] = None,
|
|
||||||
error: Optional[str] = None,
|
|
||||||
proxy: Optional[str] = None,
|
|
||||||
tag: str = "",
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
记录一条 HTTP 请求/响应详情日志。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
category: 分类(如 'douyu_login', 'geetest', 'proxy_verify', 'whitelist')
|
|
||||||
method: HTTP 方法
|
|
||||||
url: 请求 URL(会被脱敏,移除敏感查询参数)
|
|
||||||
request_headers: 请求头
|
|
||||||
request_body: 请求体
|
|
||||||
status_code: 响应状态码
|
|
||||||
response_headers: 响应头
|
|
||||||
response_body: 响应体
|
|
||||||
duration: 耗时(秒)
|
|
||||||
error: 错误信息
|
|
||||||
proxy: 使用的代理
|
|
||||||
tag: 额外标签(如账号名)
|
|
||||||
"""
|
|
||||||
entry = {
|
|
||||||
"timestamp": datetime.now().isoformat(timespec="milliseconds"),
|
|
||||||
"ts": time.time(),
|
|
||||||
"category": category,
|
|
||||||
"tag": tag,
|
|
||||||
"method": method.upper(),
|
|
||||||
"url": _truncate(url, 500),
|
|
||||||
"proxy": proxy,
|
|
||||||
"request": {
|
|
||||||
"headers": _safe_headers(request_headers),
|
|
||||||
"body": _safe_body(request_body),
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"status_code": status_code,
|
|
||||||
"headers": _safe_headers(response_headers),
|
|
||||||
"body": _truncate(response_body),
|
|
||||||
},
|
|
||||||
"duration_ms": round(duration * 1000, 1) if duration is not None else None,
|
|
||||||
"error": _truncate(error, 500) if error else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 判断级别
|
|
||||||
if error or (status_code and status_code >= 400):
|
|
||||||
entry["level"] = "error"
|
|
||||||
elif status_code and status_code >= 300:
|
|
||||||
entry["level"] = "warning"
|
|
||||||
else:
|
|
||||||
entry["level"] = "info"
|
|
||||||
|
|
||||||
try:
|
|
||||||
line = json.dumps(entry, ensure_ascii=False)
|
|
||||||
with _write_lock:
|
|
||||||
with open(_LOG_FILE, "a", encoding="utf-8") as f:
|
|
||||||
f.write(line + "\n")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"写入HTTP详情日志失败: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def read_http_logs(
|
|
||||||
*,
|
|
||||||
limit: int = 100,
|
|
||||||
offset: int = 0,
|
|
||||||
category: Optional[str] = None,
|
|
||||||
level: Optional[str] = None,
|
|
||||||
keyword: Optional[str] = None,
|
|
||||||
) -> tuple[list[dict], int]:
|
|
||||||
"""
|
|
||||||
读取 HTTP 详情日志,支持筛选和分页。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
limit: 返回条数上限
|
|
||||||
offset: 偏移量(从最新往前数)
|
|
||||||
category: 按分类筛选
|
|
||||||
level: 按级别筛选(info/warning/error)
|
|
||||||
keyword: 关键词搜索(url、tag、error)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(日志条目列表, 总匹配条数),列表按时间倒序(最新在前)
|
|
||||||
"""
|
|
||||||
if not _LOG_FILE.exists():
|
|
||||||
return [], 0
|
|
||||||
|
|
||||||
entries: list[dict] = []
|
|
||||||
try:
|
|
||||||
with open(_LOG_FILE, "r", encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
entry = json.loads(line)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 筛选
|
|
||||||
if category and entry.get("category") != category:
|
|
||||||
continue
|
|
||||||
if level and entry.get("level") != level:
|
|
||||||
continue
|
|
||||||
if keyword:
|
|
||||||
kw = keyword.lower()
|
|
||||||
searchable = " ".join([
|
|
||||||
str(entry.get("url", "")),
|
|
||||||
str(entry.get("tag", "")),
|
|
||||||
str(entry.get("error", "")),
|
|
||||||
str(entry.get("method", "")),
|
|
||||||
]).lower()
|
|
||||||
if kw not in searchable:
|
|
||||||
continue
|
|
||||||
|
|
||||||
entries.append(entry)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"读取HTTP详情日志失败: {e}")
|
|
||||||
return [], 0
|
|
||||||
|
|
||||||
# 按时间倒序
|
|
||||||
entries.sort(key=lambda x: x.get("ts", 0), reverse=True)
|
|
||||||
total = len(entries)
|
|
||||||
# 分页(offset 从最新开始算)
|
|
||||||
page = entries[offset:offset + limit]
|
|
||||||
return page, total
|
|
||||||
|
|
||||||
|
|
||||||
def clear_http_logs() -> int:
|
|
||||||
"""清空 HTTP 详情日志,返回清空的条数"""
|
|
||||||
count = 0
|
|
||||||
with _write_lock:
|
|
||||||
if _LOG_FILE.exists():
|
|
||||||
try:
|
|
||||||
with open(_LOG_FILE, "r", encoding="utf-8") as f:
|
|
||||||
count = sum(1 for line in f if line.strip())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_LOG_FILE.write_text("", encoding="utf-8")
|
|
||||||
return count
|
|
||||||
+1
-2
@@ -11,7 +11,7 @@ from fastapi.responses import FileResponse
|
|||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from .database import init_db
|
from .database import init_db
|
||||||
from .routers import auth, users, accounts, login, proxy, cookies, logs
|
from .routers import auth, users, accounts, login, proxy, cookies
|
||||||
from utils import setup_logger
|
from utils import setup_logger
|
||||||
|
|
||||||
|
|
||||||
@@ -67,7 +67,6 @@ app.include_router(accounts.router)
|
|||||||
app.include_router(login.router)
|
app.include_router(login.router)
|
||||||
app.include_router(proxy.router)
|
app.include_router(proxy.router)
|
||||||
app.include_router(cookies.router)
|
app.include_router(cookies.router)
|
||||||
app.include_router(logs.router)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
"""请求日志路由 - 查看 HTTP 请求/响应详情日志"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from ..deps import get_current_user
|
|
||||||
from ..permissions import user_has_permission
|
|
||||||
from utils.http_logger import read_http_logs, clear_http_logs
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/logs", tags=["日志"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/http")
|
|
||||||
def list_http_logs(
|
|
||||||
limit: int = Query(50, ge=1, le=500),
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
category: Optional[str] = None,
|
|
||||||
level: Optional[str] = None,
|
|
||||||
keyword: Optional[str] = None,
|
|
||||||
current=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""查看 HTTP 请求/响应详情日志(需要审计日志查看权限)"""
|
|
||||||
if not user_has_permission(current, "audit:view"):
|
|
||||||
# 运营也可以查看请求日志(用于排查登录问题)
|
|
||||||
if not user_has_permission(current, "login:batch"):
|
|
||||||
raise HTTPException(status_code=403, detail="无权限")
|
|
||||||
|
|
||||||
items, total = read_http_logs(
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
category=category,
|
|
||||||
level=level,
|
|
||||||
keyword=keyword,
|
|
||||||
)
|
|
||||||
return {"items": items, "total": total}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/http")
|
|
||||||
def clear_http_logs_api(current=Depends(get_current_user)):
|
|
||||||
"""清空 HTTP 请求/响应详情日志"""
|
|
||||||
if not user_has_permission(current, "audit:view"):
|
|
||||||
if not user_has_permission(current, "login:batch"):
|
|
||||||
raise HTTPException(status_code=403, detail="无权限")
|
|
||||||
|
|
||||||
count = clear_http_logs()
|
|
||||||
return {"success": True, "cleared": count}
|
|
||||||
@@ -11,7 +11,6 @@ import LoginTasksPage from './pages/LoginTasksPage';
|
|||||||
import ProxyPage from './pages/ProxyPage';
|
import ProxyPage from './pages/ProxyPage';
|
||||||
import UsersPage from './pages/UsersPage';
|
import UsersPage from './pages/UsersPage';
|
||||||
import CookiePage from './pages/CookiePage';
|
import CookiePage from './pages/CookiePage';
|
||||||
import HttpLogsPage from './pages/HttpLogsPage';
|
|
||||||
import { getUser } from './store/auth';
|
import { getUser } from './store/auth';
|
||||||
import { ThemeProvider, useTheme } from './store/theme';
|
import { ThemeProvider, useTheme } from './store/theme';
|
||||||
|
|
||||||
@@ -45,7 +44,6 @@ function AppContent() {
|
|||||||
<Route path="login-tasks" element={<LoginTasksPage />} />
|
<Route path="login-tasks" element={<LoginTasksPage />} />
|
||||||
<Route path="cookies" element={<CookiePage />} />
|
<Route path="cookies" element={<CookiePage />} />
|
||||||
<Route path="proxy" element={<ProxyPage />} />
|
<Route path="proxy" element={<ProxyPage />} />
|
||||||
<Route path="http-logs" element={<HttpLogsPage />} />
|
|
||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import api from './client';
|
|
||||||
import type { HttpLogClearResult, HttpLogListResult } from './types';
|
|
||||||
|
|
||||||
export const logApi = {
|
|
||||||
listHttp: (params?: { limit?: number; offset?: number; category?: string; level?: string; keyword?: string }) =>
|
|
||||||
api.get<HttpLogListResult, HttpLogListResult>('/logs/http', { params }),
|
|
||||||
clearHttp: () => api.delete<HttpLogClearResult, HttpLogClearResult>('/logs/http'),
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,6 @@ export * from './types';
|
|||||||
export { accountApi } from './accounts';
|
export { accountApi } from './accounts';
|
||||||
export { authApi } from './auth';
|
export { authApi } from './auth';
|
||||||
export { cookieApi } from './cookies';
|
export { cookieApi } from './cookies';
|
||||||
export { logApi } from './logs';
|
|
||||||
export { loginApi } from './login';
|
export { loginApi } from './login';
|
||||||
export { proxyApi } from './proxy';
|
export { proxyApi } from './proxy';
|
||||||
export { userApi } from './users';
|
export { userApi } from './users';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
DashboardOutlined, UserOutlined, LogoutOutlined,
|
DashboardOutlined, UserOutlined, LogoutOutlined,
|
||||||
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
|
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
|
||||||
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
|
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
|
||||||
SunOutlined, MoonOutlined, DesktopOutlined, FileTextOutlined,
|
SunOutlined, MoonOutlined, DesktopOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||||
import { getUser, clearAuth, type AuthUser } from '../store/auth';
|
import { getUser, clearAuth, type AuthUser } from '../store/auth';
|
||||||
@@ -71,11 +71,6 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
|||||||
menuItems.push({ key: '/proxy', label: '代理配置', icon: <CloudServerOutlined /> });
|
menuItems.push({ key: '/proxy', label: '代理配置', icon: <CloudServerOutlined /> });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求日志(运营和管理员可见)
|
|
||||||
if (canAny(['audit:view', 'login:batch'])) {
|
|
||||||
menuItems.push({ key: '/http-logs', label: '请求日志', icon: <FileTextOutlined /> });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户管理
|
// 用户管理
|
||||||
if (can('user:view')) {
|
if (can('user:view')) {
|
||||||
menuItems.push({ key: '/users', label: '用户管理', icon: <TeamOutlined /> });
|
menuItems.push({ key: '/users', label: '用户管理', icon: <TeamOutlined /> });
|
||||||
|
|||||||
@@ -1,316 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Card, Table, Tag, Space, Button, Input, Select, Tooltip, Drawer,
|
|
||||||
Typography, message, Popconfirm, Empty, Segmented,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
ReloadOutlined, DeleteOutlined, SearchOutlined, EyeOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { logApi, type HttpLogEntry } from '../api/modules';
|
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
|
||||||
import { getErrorMessage } from '../utils/error';
|
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
|
||||||
douyu_login: '斗鱼登录',
|
|
||||||
geetest: '极验',
|
|
||||||
proxy_verify: '代理验证',
|
|
||||||
whitelist: '白名单',
|
|
||||||
};
|
|
||||||
|
|
||||||
const LEVEL_COLORS: Record<string, string> = {
|
|
||||||
info: 'green',
|
|
||||||
warning: 'orange',
|
|
||||||
error: 'red',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function HttpLogsPage() {
|
|
||||||
const [logs, setLogs] = useState<HttpLogEntry[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [pageSize, setPageSize] = useState(50);
|
|
||||||
const [category, setCategory] = useState<string | undefined>(undefined);
|
|
||||||
const [level, setLevel] = useState<string | undefined>(undefined);
|
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const [detailEntry, setDetailEntry] = useState<HttpLogEntry | null>(null);
|
|
||||||
const { canAny } = usePermissions();
|
|
||||||
|
|
||||||
const fetchLogs = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await logApi.listHttp({
|
|
||||||
limit: pageSize,
|
|
||||||
offset: (page - 1) * pageSize,
|
|
||||||
category,
|
|
||||||
level,
|
|
||||||
keyword: keyword || undefined,
|
|
||||||
});
|
|
||||||
setLogs(res.items || []);
|
|
||||||
setTotal(res.total || 0);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
message.error(getErrorMessage(e) || '获取日志失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [page, pageSize, category, level, keyword]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchLogs();
|
|
||||||
}, [fetchLogs]);
|
|
||||||
|
|
||||||
// 自动刷新
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
if (!detailEntry) fetchLogs();
|
|
||||||
}, 5000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [fetchLogs, detailEntry]);
|
|
||||||
|
|
||||||
const handleClear = async () => {
|
|
||||||
try {
|
|
||||||
const res = await logApi.clearHttp();
|
|
||||||
message.success(`已清空 ${res.cleared} 条日志`);
|
|
||||||
fetchLogs();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
message.error(getErrorMessage(e) || '清空失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const canManage = canAny(['audit:view', 'login:batch']);
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: '时间',
|
|
||||||
dataIndex: 'timestamp',
|
|
||||||
width: 180,
|
|
||||||
render: (v: string) => <Text style={{ fontSize: 12 }}>{v}</Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '级别',
|
|
||||||
dataIndex: 'level',
|
|
||||||
width: 70,
|
|
||||||
render: (v: string) => <Tag color={LEVEL_COLORS[v] || 'default'}>{v}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '分类',
|
|
||||||
dataIndex: 'category',
|
|
||||||
width: 100,
|
|
||||||
render: (v: string) => CATEGORY_LABELS[v] || v,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '方法',
|
|
||||||
dataIndex: 'method',
|
|
||||||
width: 60,
|
|
||||||
render: (v: string) => <Tag>{v}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'URL',
|
|
||||||
dataIndex: 'url',
|
|
||||||
ellipsis: true,
|
|
||||||
render: (v: string) => (
|
|
||||||
<Tooltip title={v}>
|
|
||||||
<Text style={{ fontSize: 12 }} ellipsis>{v}</Text>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: ['response', 'status_code'],
|
|
||||||
width: 70,
|
|
||||||
render: (v: number | null) => v ? (
|
|
||||||
<Tag color={v < 300 ? 'green' : v < 400 ? 'blue' : 'red'}>{v}</Tag>
|
|
||||||
) : <Tag>-</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '耗时',
|
|
||||||
dataIndex: 'duration_ms',
|
|
||||||
width: 80,
|
|
||||||
render: (v: number | null) => v != null ? (
|
|
||||||
<Text style={{ fontSize: 12, color: v > 5000 ? 'red' : v > 2000 ? 'orange' : undefined }}>
|
|
||||||
{v > 1000 ? `${(v / 1000).toFixed(1)}s` : `${v}ms`}
|
|
||||||
</Text>
|
|
||||||
) : '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '代理',
|
|
||||||
dataIndex: 'proxy',
|
|
||||||
width: 140,
|
|
||||||
ellipsis: true,
|
|
||||||
render: (v: string | null) => v ? (
|
|
||||||
<Text style={{ fontSize: 12 }} type="secondary">{v}</Text>
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '标签',
|
|
||||||
dataIndex: 'tag',
|
|
||||||
width: 100,
|
|
||||||
ellipsis: true,
|
|
||||||
render: (v: string) => v ? <Text style={{ fontSize: 12 }}>{v}</Text> : null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
width: 60,
|
|
||||||
render: (_: unknown, record: HttpLogEntry) => (
|
|
||||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setDetailEntry(record)} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!canManage) {
|
|
||||||
return <Empty description="无权限查看" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Card
|
|
||||||
title="请求日志"
|
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
<Segmented
|
|
||||||
options={[
|
|
||||||
{ label: '全部', value: '' },
|
|
||||||
{ label: '信息', value: 'info' },
|
|
||||||
{ label: '警告', value: 'warning' },
|
|
||||||
{ label: '错误', value: 'error' },
|
|
||||||
]}
|
|
||||||
value={level || ''}
|
|
||||||
onChange={(v) => { setLevel(v as string || undefined); setPage(1); }}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="分类"
|
|
||||||
allowClear
|
|
||||||
style={{ width: 130 }}
|
|
||||||
value={category}
|
|
||||||
onChange={(v) => { setCategory(v); setPage(1); }}
|
|
||||||
options={Object.entries(CATEGORY_LABELS).map(([k, v]) => ({ value: k, label: v }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="搜索关键词"
|
|
||||||
allowClear
|
|
||||||
style={{ width: 180 }}
|
|
||||||
prefix={<SearchOutlined />}
|
|
||||||
value={keyword}
|
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
|
||||||
onPressEnter={() => { setPage(1); fetchLogs(); }}
|
|
||||||
/>
|
|
||||||
<Button icon={<ReloadOutlined />} onClick={fetchLogs} loading={loading}>刷新</Button>
|
|
||||||
<Popconfirm title="确定清空所有日志?" onConfirm={handleClear}>
|
|
||||||
<Button danger icon={<DeleteOutlined />}>清空</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Table
|
|
||||||
dataSource={logs}
|
|
||||||
columns={columns}
|
|
||||||
rowKey={(r) => `${r.ts}-${r.url}`}
|
|
||||||
size="small"
|
|
||||||
loading={loading}
|
|
||||||
pagination={{
|
|
||||||
current: page,
|
|
||||||
pageSize,
|
|
||||||
total,
|
|
||||||
showSizeChanger: true,
|
|
||||||
showTotal: (t) => `共 ${t} 条`,
|
|
||||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
|
||||||
}}
|
|
||||||
scroll={{ x: 1000 }}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Drawer
|
|
||||||
title="请求详情"
|
|
||||||
open={!!detailEntry}
|
|
||||||
onClose={() => setDetailEntry(null)}
|
|
||||||
width={700}
|
|
||||||
>
|
|
||||||
{detailEntry && (
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
||||||
<div>
|
|
||||||
<Text strong>时间: </Text>
|
|
||||||
<Text>{detailEntry.timestamp}</Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text strong>级别: </Text>
|
|
||||||
<Tag color={LEVEL_COLORS[detailEntry.level]}>{detailEntry.level}</Tag>
|
|
||||||
<Text strong style={{ marginLeft: 16 }}>分类: </Text>
|
|
||||||
<Tag>{CATEGORY_LABELS[detailEntry.category] || detailEntry.category}</Tag>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text strong>请求: </Text>
|
|
||||||
<Tag color="blue">{detailEntry.method}</Tag>
|
|
||||||
<Text copyable style={{ fontSize: 13 }}>{detailEntry.url}</Text>
|
|
||||||
</div>
|
|
||||||
{detailEntry.proxy && (
|
|
||||||
<div>
|
|
||||||
<Text strong>代理: </Text>
|
|
||||||
<Text code>{detailEntry.proxy}</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{detailEntry.tag && (
|
|
||||||
<div>
|
|
||||||
<Text strong>标签: </Text>
|
|
||||||
<Text>{detailEntry.tag}</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<Text strong>耗时: </Text>
|
|
||||||
<Text>{detailEntry.duration_ms != null ? `${detailEntry.duration_ms}ms` : '-'}</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{detailEntry.error ? (
|
|
||||||
<Card title="错误" size="small" style={{ borderColor: '#ff4d4f' }}>
|
|
||||||
<Paragraph type="danger" style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: 13 }}>
|
|
||||||
{detailEntry.error}
|
|
||||||
</Paragraph>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card title="响应" size="small">
|
|
||||||
<div style={{ marginBottom: 8 }}>
|
|
||||||
<Text strong>状态码: </Text>
|
|
||||||
{detailEntry.response.status_code ? (
|
|
||||||
<Tag color={detailEntry.response.status_code < 300 ? 'green' : 'red'}>
|
|
||||||
{detailEntry.response.status_code}
|
|
||||||
</Tag>
|
|
||||||
) : <Text type="secondary">-</Text>}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text strong>响应体:</Text>
|
|
||||||
<Paragraph style={{ background: 'rgba(0,0,0,0.04)', padding: 8, borderRadius: 4, whiteSpace: 'pre-wrap', fontSize: 12, margin: '4px 0 0' }}>
|
|
||||||
{detailEntry.response.body || '(空)'}
|
|
||||||
</Paragraph>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card title="请求头" size="small">
|
|
||||||
<pre style={{ fontSize: 12, margin: 0, maxHeight: 200, overflow: 'auto' }}>
|
|
||||||
{JSON.stringify(detailEntry.request.headers, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{detailEntry.request.body && (
|
|
||||||
<Card title="请求体" size="small">
|
|
||||||
<Paragraph style={{ background: 'rgba(0,0,0,0.04)', padding: 8, borderRadius: 4, whiteSpace: 'pre-wrap', fontSize: 12, margin: 0 }}>
|
|
||||||
{typeof detailEntry.request.body === 'object'
|
|
||||||
? JSON.stringify(detailEntry.request.body, null, 2)
|
|
||||||
: detailEntry.request.body}
|
|
||||||
</Paragraph>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Object.keys(detailEntry.response.headers || {}).length > 0 && (
|
|
||||||
<Card title="响应头" size="small">
|
|
||||||
<pre style={{ fontSize: 12, margin: 0, maxHeight: 200, overflow: 'auto' }}>
|
|
||||||
{JSON.stringify(detailEntry.response.headers, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user