优化代理管理与增加请求日志系统

代理优化(P0+P1):
- 修复极验失败后代理刷新空操作bug(共享ProxyManager+白名单参数)
- 极验请求超时从(3.05,12)调大到(10,30)
- 白名单sync_ip加全局锁防并发限流,保留多个出口IP应对漂移
- 代理池缓存共享:Condition防并发获取+mark_bad移除坏代理
- 适配代理API的JSON响应格式(code/data/白名单错误)
- 简化代理验证只验斗鱼主站,减少日志噪音
- 获取代理前主动同步白名单(解决ow=1模式不报白名单错误的问题)
- 每次重试重新检测出口IP并同步白名单

请求日志系统:
- 新增HttpLogger记录请求/响应详情到JSONL文件
- login.py的_request和proxy.py的verify_proxy_url接入日志
- 新增/api/logs路由查看和清空HTTP详情日志
- 前端新增请求日志页面(筛选/搜索/分页/自动刷新/详情查看)

其他:
- 添加pysocks依赖支持SOCKS5代理
- gitignore添加*.log
This commit is contained in:
yml2213
2026-06-23 00:50:57 +08:00
parent 8fdf8d4b69
commit da2fedd484
14 changed files with 1015 additions and 159 deletions
+2
View File
@@ -11,6 +11,7 @@ import LoginTasksPage from './pages/LoginTasksPage';
import ProxyPage from './pages/ProxyPage';
import UsersPage from './pages/UsersPage';
import CookiePage from './pages/CookiePage';
import HttpLogsPage from './pages/HttpLogsPage';
import { getToken } from './store/auth';
import { ThemeProvider, useTheme } from './store/theme';
@@ -44,6 +45,7 @@ function AppContent() {
<Route path="login-tasks" element={<LoginTasksPage />} />
<Route path="cookies" element={<CookiePage />} />
<Route path="proxy" element={<ProxyPage />} />
<Route path="http-logs" element={<HttpLogsPage />} />
<Route path="users" element={<UsersPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+6
View File
@@ -77,3 +77,9 @@ export const proxyApi = {
test: () => api.post<any, any>('/proxy/test'),
testWhitelist: () => api.post<any, any>('/proxy/whitelist/test'),
};
export const logApi = {
listHttp: (params?: { limit?: number; offset?: number; category?: string; level?: string; keyword?: string }) =>
api.get<any, { items: any[]; total: number }>('/logs/http', { params }),
clearHttp: () => api.delete<any, { success: boolean; cleared: number }>('/logs/http'),
};
+6 -1
View File
@@ -4,7 +4,7 @@ import {
DashboardOutlined, UserOutlined, LogoutOutlined,
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
SunOutlined, MoonOutlined, DesktopOutlined,
SunOutlined, MoonOutlined, DesktopOutlined, FileTextOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { getUser, clearAuth, hasPerm, type AuthUser } from '../store/auth';
@@ -69,6 +69,11 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
menuItems.push({ key: '/proxy', label: '代理配置', icon: <CloudServerOutlined /> });
}
// 请求日志(运营和管理员可见)
if (hasPerm(user, 'audit:view') || hasPerm(user, 'login:batch')) {
menuItems.push({ key: '/http-logs', label: '请求日志', icon: <FileTextOutlined /> });
}
// 用户管理
if (hasPerm(user, 'user:view')) {
menuItems.push({ key: '/users', label: '用户管理', icon: <TeamOutlined /> });
+330
View File
@@ -0,0 +1,330 @@
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 } from '../api/modules';
import { hasPerm, getUser } from '../store/auth';
const { Text, Paragraph } = Typography;
interface HttpLogEntry {
timestamp: string;
ts: number;
category: string;
tag: string;
method: string;
url: string;
proxy: string | null;
request: { headers: Record<string, string>; body: string };
response: { status_code: number | null; headers: Record<string, string>; body: string };
duration_ms: number | null;
error: string | null;
level: string;
}
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 user = getUser();
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: any) {
message.error(e.message || '获取日志失败');
} 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: any) {
message.error(e.message || '清空失败');
}
};
const canManage = user && (hasPerm(user, 'audit:view') || hasPerm(user, '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: (_: any, 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>
);
}