diff --git a/docker-compose.yml b/docker-compose.yml index caff4de3..2480d030 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,27 +56,26 @@ services: retries: 5 start_period: 5s -# clickhouse: -# image: clickhouse/clickhouse-server:25.3-alpine -# container_name: wavelet-clickhouse -# restart: unless-stopped -# profiles: -# - clickhouse -# environment: -# CLICKHOUSE_DB: ${CLICKHOUSE_DB:-wavelet} -# CLICKHOUSE_USER: ${CLICKHOUSE_USER:-default} -# CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-} -# CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 -# TZ: ${TZ:-Asia/Shanghai} -# ports: -# - "${CLICKHOUSE_HTTP_PORT:-8123}:8123" -# - "${CLICKHOUSE_NATIVE_PORT:-9000}:9000" -# volumes: -# - clickhouse_data:/var/lib/clickhouse -# - clickhouse_logs:/var/log/clickhouse-server -# healthcheck: -# test: ["CMD", "clickhouse-client", "--query", "SELECT 1"] -# interval: 10s -# timeout: 5s -# retries: 5 -# start_period: 15s + clickhouse: + image: clickhouse/clickhouse-server:25.3-alpine + container_name: wavelet-clickhouse + restart: unless-stopped + profiles: + - clickhouse + environment: + CLICKHOUSE_DB: ${CLICKHOUSE_DB:-wavelet} + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-default} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-123456} + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 + TZ: ${TZ:-Asia/Shanghai} + ports: + - "${CLICKHOUSE_HTTP_PORT:-8123}:8123" + - "${CLICKHOUSE_NATIVE_PORT:-9000}:9000" + volumes: + - ./data/clickhouse_data:/var/lib/clickhouse + healthcheck: + test: ["CMD", "clickhouse-client", "--query", "SELECT 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s diff --git a/frontend/components/common/admin/access-analytics.tsx b/frontend/components/common/admin/access-analytics.tsx new file mode 100644 index 00000000..6df958eb --- /dev/null +++ b/frontend/components/common/admin/access-analytics.tsx @@ -0,0 +1,318 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +"use client" + +import {useCallback, useEffect, useState} from "react" +import {BarChart3, Globe, RefreshCw, TrendingUp, Users, XCircle} from "lucide-react" +import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from "recharts" + +import {AdminService} from "@/lib/services" +import {ErrorInline} from "@/components/layout/error" +import {LoadingStateWithBorder} from "@/components/layout/loading" +import {EmptyStateWithBorder} from "@/components/layout/empty" +import {Badge} from "@/components/ui/badge" +import {Button} from "@/components/ui/button" +import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" +import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent} from "@/components/ui/chart" +import {Spinner} from "@/components/ui/spinner" + +interface TrendData { + date: string + count: number +} + +interface BrowserData { + browser: string + count: number +} + +interface TopUserData { + user_id: string + username: string + nickname: string + count: number +} + +const chartConfig = { + count: { + label: "请求量", + color: "hsl(var(--primary))", + }, +} satisfies ChartConfig + +// Format YYYY-MM-DD to MM/DD +function formatDateLabel(dateStr: string) { + if (!dateStr || dateStr.length < 10) return dateStr + const parts = dateStr.split("-") + if (parts.length === 3) { + return `${parts[1]}/${parts[2]}` + } + return dateStr +} + +export function AccessAnalytics() { + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [clickhouseDisabled, setClickhouseDisabled] = useState(false) + + const [trend, setTrend] = useState([]) + const [browsers, setBrowsers] = useState([]) + const [topUsers, setTopUsers] = useState([]) + + const fetchAnalytics = useCallback(async () => { + try { + setLoading(true) + setError(null) + const data = await AdminService.getLogsAnalytics() + + // Formats the date labels for the X-axis representation + const formattedTrend = (data.trend || []).map(item => ({ + ...item, + formattedDate: formatDateLabel(item.date) + })) + + setTrend(formattedTrend) + setBrowsers(data.browsers || []) + setTopUsers(data.top_users || []) + setClickhouseDisabled(false) + } catch (err: any) { + const errMsg = err?.message || "" + if (errMsg.includes("ClickHouse") || errMsg.includes("未启用")) { + setClickhouseDisabled(true) + } else { + setError(err instanceof Error ? err : new Error("获取数据统计失败")) + } + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchAnalytics() + }, [fetchAnalytics]) + + const totalBrowserRequests = browsers.reduce((sum, item) => sum + item.count, 0) + const totalTrendRequests = trend.reduce((sum, item) => sum + item.count, 0) + + if (clickhouseDisabled) { + return ( +
+ +

ClickHouse 未启用

+

+ 当前系统配置未启用 ClickHouse 存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml` 配置文件中配置并启用 ClickHouse。 +

+
+ ) + } + + if (error) { + return + } + + if (loading) { + return + } + + return ( +
+ {/* Overview Cards */} +
+ + + 近 7 天总请求数 + + + +
{totalTrendRequests.toLocaleString()}
+

系统记录的所有成功认证的访问总频次

+
+
+ + + 活跃终端分类数 + + + +
{browsers.length}
+

在一周内发起请求的浏览器代理大类统计

+
+
+ + + 活跃独立用户数 + + + +
{topUsers.length}
+

一周内累计发起高频请求的注册账户总量

+
+
+
+ + {/* Access Trend Chart */} + + +
+ 一周访问量趋势 + + 展现系统最近 7 天内的每日 API 请求曲线 + +
+ +
+ + {trend.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + } /> + + + +
+ )} +
+
+ + {/* Two Columns for Ranking Statistics */} +
+ {/* Browser Rankings */} + + + + + 使用的浏览器排行 + + + 基于请求头 User-Agent 智能分类的一周占比统计 + + + + {browsers.length === 0 ? ( + + ) : ( +
+ {browsers.map((item, index) => { + const percent = totalBrowserRequests > 0 ? (item.count / totalBrowserRequests) * 100 : 0 + return ( +
+
+ + + {index + 1} + + {item.browser} + + + {item.count.toLocaleString()} 次 ({percent.toFixed(1)}%) + +
+
+
+
+
+ ) + })} +
+ )} + + + + {/* Top Users */} + + + + + 最活跃的用户排行 (Top 10) + + + 统计最近一周发起接口访问请求数量最多的账户 + + + + {topUsers.length === 0 ? ( + + ) : ( +
+ {topUsers.map((user, index) => ( +
+
+
+ {(user.username || "U").slice(0, 1).toUpperCase()} +
+
+
+ {user.username || "未知"} +
+
+ {user.nickname ? `(${user.nickname})` : "(无昵称)"} | ID: {user.user_id} +
+
+
+
+
{user.count.toLocaleString()}
+
次请求
+
+
+ ))} +
+ )} +
+
+
+
+ ) +} diff --git a/frontend/components/common/admin/access-logs.tsx b/frontend/components/common/admin/access-logs.tsx new file mode 100644 index 00000000..48d960fd --- /dev/null +++ b/frontend/components/common/admin/access-logs.tsx @@ -0,0 +1,467 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +"use client" + +import {useCallback, useEffect, useState} from "react" +import {toast} from "sonner" +import {format} from "date-fns" +import {Activity, ChevronLeft, ChevronRight, Copy, Eye, RotateCcw, Search, XCircle} from "lucide-react" + +import {AdminService} from "@/lib/services" +import {ErrorInline} from "@/components/layout/error" +import {LoadingStateWithBorder} from "@/components/layout/loading" +import {EmptyStateWithBorder} from "@/components/layout/empty" +import {Badge} from "@/components/ui/badge" +import {Button} from "@/components/ui/button" +import {Input} from "@/components/ui/input" +import {Label} from "@/components/ui/label" +import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet" +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table" + +interface AccessLog { + id: string + user_id: string + username: string + nickname: string + path: string + method: string + ip: string + user_agent: string + headers: string + status: number + latency: number + created_at: string +} + +function formatDateTime(value?: string | null) { + if (!value) return "-" + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return format(date, "yyyy-MM-dd HH:mm:ss") +} + +function formatLatency(ms: number) { + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +function statusVariant(status: number) { + if (status >= 500) return "destructive" + if (status >= 400) return "destructive" + if (status >= 300) return "outline" + return "secondary" +} + +export function AccessLogs() { + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [clickhouseDisabled, setClickhouseDisabled] = useState(false) + + const [logs, setLogs] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const pageSize = 15 + + // 搜索过滤条件 + const [usernameFilter, setUsernameFilter] = useState("") + const [pathFilter, setPathFilter] = useState("") + const [startTimeFilter, setStartTimeFilter] = useState("") + const [endTimeFilter, setEndTimeFilter] = useState("") + + // 实际提交的搜索条件 + const [searchParams, setSearchParams] = useState({ + username: "", + path: "", + start_time: "", + end_time: "", + }) + + const [selectedLog, setSelectedLog] = useState(null) + const [detailOpen, setDetailOpen] = useState(false) + + const fetchAccessLogs = useCallback(async (currentPage: number = page) => { + try { + setLoading(true) + setError(null) + + // 转换起止时间格式 + let startISO = "" + if (searchParams.start_time) { + startISO = new Date(searchParams.start_time).toISOString() + } + let endISO = "" + if (searchParams.end_time) { + endISO = new Date(searchParams.end_time).toISOString() + } + + const data = await AdminService.getAccessLogs({ + page: currentPage, + page_size: pageSize, + username: searchParams.username || undefined, + path: searchParams.path || undefined, + start_time: startISO || undefined, + end_time: endISO || undefined, + }) + + setLogs(data.list || []) + setTotal(data.total || 0) + setClickhouseDisabled(false) + } catch (err: any) { + const errMsg = err?.message || "" + if (errMsg.includes("ClickHouse") || errMsg.includes("未启用")) { + setClickhouseDisabled(true) + } else { + setError(err instanceof Error ? err : new Error("获取访问日志失败")) + } + } finally { + setLoading(false) + } + }, [page, searchParams]) + + useEffect(() => { + fetchAccessLogs() + }, [fetchAccessLogs]) + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault() + setPage(1) + setSearchParams({ + username: usernameFilter.trim(), + path: pathFilter.trim(), + start_time: startTimeFilter, + end_time: endTimeFilter, + }) + } + + const handleReset = () => { + setUsernameFilter("") + setPathFilter("") + setStartTimeFilter("") + setEndTimeFilter("") + setPage(1) + setSearchParams({ + username: "", + path: "", + start_time: "", + end_time: "", + }) + } + + const handlePageChange = (newPage: number) => { + setPage(newPage) + fetchAccessLogs(newPage) + } + + const copyToClipboard = (text: string, subject: string) => { + navigator.clipboard.writeText(text) + toast.success(`${subject}已复制到剪贴板`) + } + + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + + // Prettify Headers JSON string + const getPrettyHeaders = (headersRaw?: string) => { + if (!headersRaw) return "暂无头部数据" + try { + const parsed = JSON.parse(headersRaw) + return JSON.stringify(parsed, null, 2) + } catch { + return headersRaw + } + } + + if (clickhouseDisabled) { + return ( +
+ +

ClickHouse 未启用

+

+ 当前系统配置未启用 ClickHouse 存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml` 配置文件中配置并启用 ClickHouse。 +

+
+ ) + } + + if (error) { + return fetchAccessLogs(1)} /> + } + + return ( +
+ {/* Filters */} +
+
+ + setUsernameFilter(e.target.value)} + className="h-8 text-xs" + /> +
+
+ + setPathFilter(e.target.value)} + className="h-8 text-xs" + /> +
+
+ + setStartTimeFilter(e.target.value)} + className="h-8 text-xs" + /> +
+
+ + setEndTimeFilter(e.target.value)} + className="h-8 text-xs" + /> +
+
+ + +
+
+ + {/* Loading Table */} + {loading && logs.length === 0 ? ( + + ) : logs.length === 0 ? ( + + ) : ( +
+ + + + 请求方法 + 路径 + 用户 + IP + 状态 + 耗时 + 请求时间 + 详情 + + + + {logs.map((log) => ( + + + + {log.method} + + + + {log.path} + + +
+ {log.username || "未知用户"} + {log.nickname && ( + ({log.nickname}) + )} +
+
+ + {log.ip} + + + + {log.status} + + + + {formatLatency(log.latency)} + + + {formatDateTime(log.created_at)} + + + + +
+ ))} +
+
+
+ )} + + {/* Pagination */} + {logs.length > 0 && ( +
+
+ 共 {total} 条记录,当前第 {page}/{totalPages} 页 +
+
+ + +
+
+ )} + + {/* Detail Drawer */} + + + + 访问日志详情 + + 访问 ID: {selectedLog?.id} + + + + {selectedLog ? ( +
+ {/* Summary Details */} +
+
+
请求方法
+
{selectedLog.method}
+
+
+
响应状态
+
+ + {selectedLog.status} + +
+
+
+
耗时
+
{formatLatency(selectedLog.latency)}
+
+
+
IP 地址
+
{selectedLog.ip}
+
+
+
用户
+
+ {selectedLog.username ? `${selectedLog.username} (${selectedLog.nickname || '无昵称'})` : "未知/游客"} + ID: {selectedLog.user_id} +
+
+
+ + {/* Path */} +
+ +
+ {selectedLog.path} +
+
+ + {/* Request Time */} +
+ +
{formatDateTime(selectedLog.created_at)}
+
+ + {/* User Agent */} +
+
+ + +
+
+ {selectedLog.user_agent} +
+
+ + {/* Headers */} +
+
+ + +
+
+									{getPrettyHeaders(selectedLog.headers)}
+								
+
+
+ ) : ( +
+ +
+ )} +
+
+
+ ) +} diff --git a/frontend/components/common/admin/app-logs.tsx b/frontend/components/common/admin/app-logs.tsx new file mode 100644 index 00000000..094bbb30 --- /dev/null +++ b/frontend/components/common/admin/app-logs.tsx @@ -0,0 +1,335 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +"use client" + +import {useCallback, useEffect, useRef, useState} from "react" +import {toast} from "sonner" +import {ArrowDown, ChevronUp, Loader2, Pause, Play} from "lucide-react" + +import {AdminService} from "@/lib/services" +import {ErrorInline} from "@/components/layout/error" +import {LoadingStateWithBorder} from "@/components/layout/loading" +import {Badge} from "@/components/ui/badge" +import {Button} from "@/components/ui/button" + +interface LogEntry { + index: number + data: string +} + +// Distance (px) from bottom to treat as "at bottom" +const BOTTOM_THRESHOLD = 40 + +function getApiBaseUrl(): string { + if (typeof window !== "undefined") { + // If NEXT_PUBLIC_WAVELET_BACKEND_URL is set, use it. Otherwise, use origin. + const base = process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || "" + if (base.startsWith("http")) return base + + // Relative URL fallback + const proto = window.location.protocol + const host = window.location.host + return `${proto}//${host}${base}` + } + return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || "" +} + +function buildWsUrl(): string { + const base = getApiBaseUrl() + const wsBase = base.replace(/^http/, "ws") + return `${wsBase}/api/v1/admin/logs/ws` +} + +function parseLogLevel(line: string): "debug" | "info" | "warn" | "error" | "unknown" { + const lower = line.toLowerCase() + if (lower.includes("\"level\":\"error\"") || lower.includes("level=error")) return "error" + if (lower.includes("\"level\":\"warn\"") || lower.includes("level=warn")) return "warn" + if (lower.includes("\"level\":\"debug\"") || lower.includes("level=debug")) return "debug" + if (lower.includes("\"level\":\"info\"") || lower.includes("level=info")) return "info" + return "unknown" +} + +export function AppLogs() { + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const [logs, setLogs] = useState([]) + const [hasMore, setHasMore] = useState(false) + const [nextCursor, setNextCursor] = useState(0) + const [loadingMore, setLoadingMore] = useState(false) + + const [connected, setConnected] = useState(false) + const [paused, setPaused] = useState(false) + + // autoScroll = true → new logs auto-scroll to bottom + const [autoScroll, setAutoScroll] = useState(true) + + const containerRef = useRef(null) + const wsRef = useRef(null) + const pausedRef = useRef(paused) + const autoScrollRef = useRef(autoScroll) + const isUserScrolling = useRef(false) + + useEffect(() => { pausedRef.current = paused }, [paused]) + useEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll]) + + // ---- Scroll detection ------------------------------------------------ + const handleScroll = useCallback(() => { + const el = containerRef.current + if (!el) return + + if (!isUserScrolling.current) return + + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD + if (atBottom && !autoScrollRef.current) { + setAutoScroll(true) + } else if (!atBottom && autoScrollRef.current) { + setAutoScroll(false) + } + }, []) + + const handleWheel = useCallback(() => { isUserScrolling.current = true }, []) + const handleTouchStart = useCallback(() => { isUserScrolling.current = true }, []) + + useEffect(() => { + const el = containerRef.current + if (!el) return + + let timer: ReturnType + const onScrollEnd = () => { + clearTimeout(timer) + timer = setTimeout(() => { isUserScrolling.current = false }, 150) + } + el.addEventListener("scroll", onScrollEnd, { passive: true }) + return () => { + el.removeEventListener("scroll", onScrollEnd) + clearTimeout(timer) + } + }, []) + + // ---- Auto-scroll to bottom when new logs arrive ---------------------- + useEffect(() => { + if (!autoScroll || !containerRef.current) return + isUserScrolling.current = false + const el = containerRef.current + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight + }) + }, [logs, autoScroll]) + + // ---- Data fetching --------------------------------------------------- + const fetchLogs = useCallback(async (cursor: number = 0) => { + try { + return await AdminService.getLogs(cursor) + } catch (err) { + throw err instanceof Error ? err : new Error("获取日志失败") + } + }, []) + + const loadHistory = useCallback(async (cursor: number = 0) => { + const isInitial = cursor === 0 + if (isInitial) { + setLoading(true) + setError(null) + } else { + setLoadingMore(true) + } + + try { + const data = await fetchLogs(cursor) + if (isInitial) { + setLogs(data.lines || []) + } else { + setLogs(prev => [...(data.lines || []), ...prev]) + + requestAnimationFrame(() => { + const el = containerRef.current + if (!el) return + const newCount = (data.lines || []).length + const lineH = 20 + isUserScrolling.current = false + el.scrollTop = el.scrollTop + newCount * lineH + }) + } + setHasMore(data.has_more) + setNextCursor(data.next_cursor) + } catch (err) { + if (isInitial) { + setError(err instanceof Error ? err : new Error("获取日志失败")) + } else { + toast.error("加载更早日志失败") + } + } finally { + if (isInitial) setLoading(false) + else setLoadingMore(false) + } + }, [fetchLogs]) + + // ---- WebSocket ------------------------------------------------------- + const connectWs = useCallback(() => { + if (wsRef.current) wsRef.current.close() + + const ws = new WebSocket(buildWsUrl()) + wsRef.current = ws + + ws.onopen = () => { setConnected(true) } + + ws.onmessage = (event) => { + if (pausedRef.current) return + try { + const msg = JSON.parse(event.data) + if (msg.type === "log" && msg.data) { + const entry: LogEntry = msg.data + setLogs(prev => { + const next = [...prev, entry] + return next.length > 2000 ? next.slice(-2000) : next + }) + } + } catch { /* ignore */ } + } + + ws.onclose = () => { setConnected(false); wsRef.current = null } + ws.onerror = () => { setConnected(false) } + }, []) + + // ---- Initialize ------------------------------------------------------ + useEffect(() => { + loadHistory(0).then(() => connectWs()) + return () => { + wsRef.current?.close() + wsRef.current = null + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // ---- Actions --------------------------------------------------------- + const scrollToBottom = useCallback(() => { + setAutoScroll(true) + requestAnimationFrame(() => { + if (containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight + } + }) + }, []) + + const togglePause = useCallback(() => setPaused(p => !p), []) + const reconnect = useCallback(() => connectWs(), [connectWs]) + const handleLoadMore = useCallback(() => { + if (nextCursor > 0) loadHistory(nextCursor) + }, [nextCursor, loadHistory]) + + // ---- Render ---------------------------------------------------------- + if (loading) return + if (error) return loadHistory(0)} /> + + return ( +
+ {/* Sub Header / Control Bar */} +
+
+ 系统后台实时输出的运行日志 (最多缓存 2000 行) +
+
+ + {connected ? "已连接" : "断开连接"} + + {connected && ( + + )} + {!connected && ( + + )} +
+
+ + {/* Log viewer — fixed height, scrollable */} +
+ {/* Load older logs */} + {hasMore && ( +
+ +
+ )} + + {/* Log lines */} +
+ {logs.length === 0 ? ( +
暂无日志
+ ) : ( + logs.map((entry) => { + const level = parseLogLevel(entry.data) + const color = level === "error" + ? "text-red-400" + : level === "warn" + ? "text-yellow-400" + : level === "debug" + ? "text-gray-500" + : "text-gray-300" + return ( +
+ {entry.data} +
+ ) + }) + )} +
+
+ + {/* Floating "back to latest" button */} + {!autoScroll && ( +
+ +
+ )} +
+ ) +} diff --git a/frontend/components/common/admin/system-logs.tsx b/frontend/components/common/admin/system-logs.tsx index 59beadb3..f87b968b 100644 --- a/frontend/components/common/admin/system-logs.tsx +++ b/frontend/components/common/admin/system-logs.tsx @@ -1,334 +1,69 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + "use client" -import {useCallback, useEffect, useRef, useState} from "react" -import {toast} from "sonner" -import {ArrowDown, ChevronUp, Loader2, Pause, Play, Terminal} from "lucide-react" +import {Activity, BarChart3, Terminal} from "lucide-react" -import {AdminService} from "@/lib/services" -import {ErrorInline} from "@/components/layout/error" -import {LoadingStateWithBorder} from "@/components/layout/loading" -import {Badge} from "@/components/ui/badge" -import {Button} from "@/components/ui/button" - -interface LogEntry { - index: number - data: string -} - -function getApiBaseUrl(): string { - return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || "" -} - -function buildWsUrl(): string { - const base = getApiBaseUrl() - const wsBase = base.replace(/^http/, "ws") - return `${wsBase}/api/v1/admin/logs/ws` -} - -function parseLogLevel(line: string): "debug" | "info" | "warn" | "error" | "unknown" { - const lower = line.toLowerCase() - if (lower.includes("\"level\":\"error\"") || lower.includes("level=error")) return "error" - if (lower.includes("\"level\":\"warn\"") || lower.includes("level=warn")) return "warn" - if (lower.includes("\"level\":\"debug\"") || lower.includes("level=debug")) return "debug" - if (lower.includes("\"level\":\"info\"") || lower.includes("level=info")) return "info" - return "unknown" -} - -// Distance (px) from bottom to treat as "at bottom" — keeps auto-scroll -// active even when the user's last row is a few pixels shy of the edge. -const BOTTOM_THRESHOLD = 40 +import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs" +import {AppLogs} from "./app-logs" +import {AccessLogs} from "./access-logs" +import {AccessAnalytics} from "./access-analytics" export function SystemLogs() { - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const [logs, setLogs] = useState([]) - const [hasMore, setHasMore] = useState(false) - const [nextCursor, setNextCursor] = useState(0) - const [loadingMore, setLoadingMore] = useState(false) - - const [connected, setConnected] = useState(false) - const [paused, setPaused] = useState(false) - - // autoScroll = true → new logs auto-scroll to bottom - // autoScroll = false → user is browsing history, lock scroll position - const [autoScroll, setAutoScroll] = useState(true) - - const containerRef = useRef(null) - const wsRef = useRef(null) - const pausedRef = useRef(paused) - const autoScrollRef = useRef(autoScroll) - const isUserScrolling = useRef(false) - - // Keep refs in sync for use inside callbacks without stale closures - useEffect(() => { pausedRef.current = paused }, [paused]) - useEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll]) - - // ---- Scroll detection ------------------------------------------------ - - // We distinguish programmatic scrolls (triggered by our own auto-scroll or - // load-history offset fixup) from user-initiated scrolls by gating with - // the `isUserScrolling` flag. Only a user scroll can toggle `autoScroll`. - - const handleScroll = useCallback(() => { - const el = containerRef.current - if (!el) return - - // Ignore programmatic scrolls - if (!isUserScrolling.current) return - - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD - if (atBottom && !autoScrollRef.current) { - setAutoScroll(true) - } else if (!atBottom && autoScrollRef.current) { - setAutoScroll(false) - } - }, []) - - // Mark user-initiated scrolls - const handleWheel = useCallback(() => { isUserScrolling.current = true }, []) - - // Touch devices - const handleTouchStart = useCallback(() => { isUserScrolling.current = true }, []) - - // After the user stops scrolling, reset the flag so our programmatic - // scrolls won't accidentally toggle autoScroll. - useEffect(() => { - const el = containerRef.current - if (!el) return - - let timer: ReturnType - const onScrollEnd = () => { - clearTimeout(timer) - timer = setTimeout(() => { isUserScrolling.current = false }, 150) - } - el.addEventListener("scroll", onScrollEnd, { passive: true }) - return () => { - el.removeEventListener("scroll", onScrollEnd) - clearTimeout(timer) - } - }, []) - - // ---- Auto-scroll to bottom when new logs arrive ---------------------- - - useEffect(() => { - if (!autoScroll || !containerRef.current) return - isUserScrolling.current = false - const el = containerRef.current - requestAnimationFrame(() => { - el.scrollTop = el.scrollHeight - }) - }, [logs, autoScroll]) - - // ---- Data fetching --------------------------------------------------- - - const fetchLogs = useCallback(async (cursor: number = 0) => { - try { - return await AdminService.getLogs(cursor) - } catch (err) { - throw err instanceof Error ? err : new Error("获取日志失败") - } - }, []) - - const loadHistory = useCallback(async (cursor: number = 0) => { - const isInitial = cursor === 0 - if (isInitial) { - setLoading(true) - setError(null) - } else { - setLoadingMore(true) - } - - try { - const data = await fetchLogs(cursor) - if (isInitial) { - setLogs(data.lines || []) - } else { - // Prepend older logs; keep the viewport showing the same content - // by restoring the scroll offset after the DOM update. - setLogs(prev => [...(data.lines || []), ...prev]) - - // Wait for React to render, then compensate scroll position - requestAnimationFrame(() => { - const el = containerRef.current - if (!el) return - // The number of new rows prepended × approximate line height - const newCount = (data.lines || []).length - const lineH = 20 // matches leading-5 ≈ 20px - isUserScrolling.current = false - el.scrollTop = el.scrollTop + newCount * lineH - }) - } - setHasMore(data.has_more) - setNextCursor(data.next_cursor) - } catch (err) { - if (isInitial) { - setError(err instanceof Error ? err : new Error("获取日志失败")) - } else { - toast.error("加载更早日志失败") - } - } finally { - if (isInitial) setLoading(false) - else setLoadingMore(false) - } - }, [fetchLogs]) - - // ---- WebSocket ------------------------------------------------------- - - const connectWs = useCallback(() => { - if (wsRef.current) wsRef.current.close() - - const ws = new WebSocket(buildWsUrl()) - wsRef.current = ws - - ws.onopen = () => { setConnected(true) } - - ws.onmessage = (event) => { - if (pausedRef.current) return - try { - const msg = JSON.parse(event.data) - if (msg.type === "log" && msg.data) { - const entry: LogEntry = msg.data - setLogs(prev => { - const next = [...prev, entry] - return next.length > 2000 ? next.slice(-2000) : next - }) - } - } catch { /* ignore */ } - } - - ws.onclose = () => { setConnected(false); wsRef.current = null } - ws.onerror = () => { setConnected(false) } - }, []) - - // ---- Initialize ------------------------------------------------------ - - useEffect(() => { - loadHistory(0).then(() => connectWs()) - return () => { - wsRef.current?.close() - wsRef.current = null - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // ---- Actions --------------------------------------------------------- - - const scrollToBottom = useCallback(() => { - setAutoScroll(true) - requestAnimationFrame(() => { - if (containerRef.current) { - containerRef.current.scrollTop = containerRef.current.scrollHeight - } - }) - }, []) - - const togglePause = useCallback(() => setPaused(p => !p), []) - const reconnect = useCallback(() => connectWs(), [connectWs]) - const handleLoadMore = useCallback(() => { - if (nextCursor > 0) loadHistory(nextCursor) - }, [nextCursor, loadHistory]) - - // ---- Render ---------------------------------------------------------- - - if (loading) return - if (error) return loadHistory(0)} /> - return ( -
+
{/* Header */} -
+
-

系统日志

-
-
- - {connected ? "已连接" : "未连接"} - - {connected && ( - - )} - {!connected && ( - - )} -
-
- - {/* Log viewer — fixed height, scrollable */} -
- {/* Load older logs */} - {hasMore && ( -
- +
+

系统日志

- )} - - {/* Log lines */} -
- {logs.length === 0 ? ( -
暂无日志
- ) : ( - logs.map((entry) => { - const level = parseLogLevel(entry.data) - const color = level === "error" - ? "text-red-400" - : level === "warn" - ? "text-yellow-400" - : level === "debug" - ? "text-gray-500" - : "text-gray-300" - return ( -
- {entry.data} -
- ) - }) - )}
- {/* Floating "back to latest" button */} - {!autoScroll && ( -
- -
- )} + {/* Tabs Layout */} + + + + + 访问分析 + + + + 用户访问日志 (ClickHouse) + + + + 应用运行日志 (Console) + + + + + + + + + + + + +
) } + diff --git a/frontend/lib/services/admin/admin.service.ts b/frontend/lib/services/admin/admin.service.ts index b22319f5..1d2f37d2 100644 --- a/frontend/lib/services/admin/admin.service.ts +++ b/frontend/lib/services/admin/admin.service.ts @@ -351,6 +351,52 @@ export class AdminService extends BaseService { return this.get('/logs', { cursor, limit }); } + /** + * 获取 ClickHouse 访问日志列表 + */ + static async getAccessLogs(params: { + page: number; + page_size: number; + username?: string; + path?: string; + start_time?: string; + end_time?: string; + }): Promise<{ + total: number; + list: Array<{ + id: string; + user_id: string; + username: string; + nickname: string; + path: string; + method: string; + ip: string; + user_agent: string; + headers: string; + status: number; + latency: number; + created_at: string; + }>; + }> { + return this.get('/logs/access', params as Record); + } + + /** + * 获取 ClickHouse 访问日志图表聚合指标 + */ + static async getLogsAnalytics(): Promise<{ + trend: Array<{ date: string; count: number }>; + browsers: Array<{ browser: string; count: number }>; + top_users: Array<{ + user_id: string; + username: string; + nickname: string; + count: number; + }>; + }> { + return this.get('/logs/analytics'); + } + // ==================== 模板管理 ==================== /** diff --git a/frontend/next.config.ts b/frontend/next.config.ts index f9b014fc..20693cb0 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -10,21 +10,6 @@ const nextConfig: NextConfig = { async rewrites() { const backendUrl = process.env.WAVELET_BACKEND_URL || 'http://localhost:8000'; return [ - // 易支付兼容接口 - 创建订单 - { - source: '/epay/pay/:path*', - destination: `${ backendUrl }/pay/:path*`, - }, - // 易支付兼容接口 - 查询订单和退款 - { - source: '/epay/api.php', - destination: `${ backendUrl }/api.php`, - }, - // Credit 协议接口 - 商户分发 - { - source: '/lpay/distribute', - destination: `${ backendUrl }/pay/distribute`, - }, // 上传文件静态资源 { source: '/f/:id', diff --git a/internal/apps/admin/logs/routers.go b/internal/apps/admin/logs/routers.go index 66396c91..7fd4a731 100644 --- a/internal/apps/admin/logs/routers.go +++ b/internal/apps/admin/logs/routers.go @@ -19,10 +19,18 @@ package logs import ( "encoding/json" + "fmt" "net/http" + "sort" + "strconv" + "strings" + "time" "github.com/Rain-kl/Wavelet/internal/apps/admin" + "github.com/Rain-kl/Wavelet/internal/config" + "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/logger" + "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/util" "github.com/gin-gonic/gin" ) @@ -131,3 +139,429 @@ func HandleLogWebSocket(c *gin.Context) { } } } + +// accessLogItem 访问日志单条数据 +type accessLogItem struct { + ID uint64 `json:"id,string"` + UserID uint64 `json:"user_id,string"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Path string `json:"path"` + Method string `json:"method"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + Headers string `json:"headers"` + Status int32 `json:"status"` + Latency int64 `json:"latency"` + CreatedAt string `json:"created_at"` +} + +// accessLogsResponse 访问日志查询响应 +type accessLogsResponse struct { + Total uint64 `json:"total"` + List []accessLogItem `json:"list"` +} + +// GetAccessLogs 获取 ClickHouse 异步采集的访问日志 +// @Summary 获取用户访问日志 +// @Description 分页并按照用户、接口路径、时间范围等维度检索 ClickHouse 用户访问日志列表(需要管理员权限,ClickHouse 未启用时报错) +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页条数" default(20) +// @Param username query string false "用户名模糊搜索" +// @Param path query string false "接口路径模糊搜索" +// @Param start_time query string false "起始时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)" +// @Param end_time query string false "结束时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)" +// @Success 200 {object} util.ResponseAny{data=logs.accessLogsResponse} "访问日志列表" +// @Failure 400 {object} util.ResponseAny "ClickHouse 未启用或参数错误" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Router /api/v1/admin/logs/access [get] +func GetAccessLogs(c *gin.Context) { + // 1. 检查 ClickHouse 是否启用 + if !config.Config.ClickHouse.Enabled || db.ChConn == nil { + c.JSON(http.StatusBadRequest, util.Err("ClickHouse 存储服务未启用,无法检索访问日志")) + return + } + + // 2. 解析分页参数 + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + offset := (page - 1) * pageSize + + // 3. 按用户名过滤(预查 Postgres 映射 UserID) + var userIDs []uint64 + username := c.Query("username") + if username != "" { + err := db.DB(c.Request.Context()).Model(&model.User{}). + Where("username LIKE ?", "%"+username+"%"). + Pluck("id", &userIDs).Error + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err("查询用户信息失败: "+err.Error())) + return + } + // 如果指定了用户名搜索,但在 Postgres 中没匹配到任何用户,则直接返回空结果 + if len(userIDs) == 0 { + c.JSON(http.StatusOK, util.OK(accessLogsResponse{ + Total: 0, + List: []accessLogItem{}, + })) + return + } + } + + // 4. 构建 ClickHouse 条件查询子句与参数 + var conditions []string + var args []interface{} + + if len(userIDs) > 0 { + placeholders := make([]string, len(userIDs)) + for i := range userIDs { + placeholders[i] = "?" + args = append(args, userIDs[i]) + } + conditions = append(conditions, fmt.Sprintf("user_id IN (%s)", strings.Join(placeholders, ","))) + } + + if path := c.Query("path"); path != "" { + conditions = append(conditions, "path LIKE ?") + args = append(args, "%"+path+"%") + } + + if startTime := c.Query("start_time"); startTime != "" { + if t, err := time.Parse(time.RFC3339, startTime); err == nil { + conditions = append(conditions, "created_at >= ?") + args = append(args, t) + } else if t, err := time.Parse("2006-01-02 15:04:05", startTime); err == nil { + conditions = append(conditions, "created_at >= ?") + args = append(args, t) + } + } + + if endTime := c.Query("end_time"); endTime != "" { + if t, err := time.Parse(time.RFC3339, endTime); err == nil { + conditions = append(conditions, "created_at <= ?") + args = append(args, t) + } else if t, err := time.Parse("2006-01-02 15:04:05", endTime); err == nil { + conditions = append(conditions, "created_at <= ?") + args = append(args, t) + } + } + + whereClause := "" + if len(conditions) > 0 { + whereClause = "WHERE " + strings.Join(conditions, " AND ") + } + + // 5. 查询日志总数 + var total uint64 + countQuery := fmt.Sprintf("SELECT count() FROM user_access_logs %s", whereClause) + err := db.ChConn.QueryRow(c.Request.Context(), countQuery, args...).Scan(&total) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err("查询 ClickHouse 日志统计失败: "+err.Error())) + return + } + + if total == 0 { + c.JSON(http.StatusOK, util.OK(accessLogsResponse{ + Total: 0, + List: []accessLogItem{}, + })) + return + } + + // 6. 分页查询明细数据 + dataQuery := fmt.Sprintf(` + SELECT id, user_id, path, method, ip, user_agent, headers, status, latency, created_at + FROM user_access_logs + %s + ORDER BY created_at DESC, id DESC + LIMIT ? OFFSET ? + `, whereClause) + + selectArgs := append(args, pageSize, offset) + rows, err := db.ChConn.Query(c.Request.Context(), dataQuery, selectArgs...) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err("查询 ClickHouse 日志明细失败: "+err.Error())) + return + } + defer rows.Close() + + var list []accessLogItem + var fetchUserIDs []uint64 + + for rows.Next() { + var item accessLogItem + var createdAt time.Time + err := rows.Scan( + &item.ID, + &item.UserID, + &item.Path, + &item.Method, + &item.IP, + &item.UserAgent, + &item.Headers, + &item.Status, + &item.Latency, + &createdAt, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err("读取 ClickHouse 结果失败: "+err.Error())) + return + } + item.CreatedAt = createdAt.Format(time.RFC3339) + list = append(list, item) + fetchUserIDs = append(fetchUserIDs, item.UserID) + } + + // 7. 反查 Postgres 关联 Username 和 Nickname + userMap := make(map[uint64]struct { + Username string + Nickname string + }) + + if len(fetchUserIDs) > 0 { + var users []model.User + if err := db.DB(c.Request.Context()).Where("id IN ?", fetchUserIDs).Find(&users).Error; err == nil { + for _, u := range users { + userMap[u.ID] = struct { + Username string + Nickname string + }{ + Username: u.Username, + Nickname: u.Nickname, + } + } + } + } + + for i := range list { + if info, ok := userMap[list[i].UserID]; ok { + list[i].Username = info.Username + list[i].Nickname = info.Nickname + } + } + + c.JSON(http.StatusOK, util.OK(accessLogsResponse{ + Total: total, + List: list, + })) +} + +// trendItem 趋势图数据点 +type trendItem struct { + Date string `json:"date"` + Count uint64 `json:"count"` +} + +// browserItem 浏览器占比排行 +type browserItem struct { + Browser string `json:"browser"` + Count uint64 `json:"count"` +} + +// topUserItem 活跃用户数据 +type topUserItem struct { + UserID uint64 `json:"user_id,string"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Count uint64 `json:"count"` +} + +// logsAnalyticsResponse 访问日志数据分析结果 +type logsAnalyticsResponse struct { + Trend []trendItem `json:"trend"` + Browsers []browserItem `json:"browsers"` + TopUsers []topUserItem `json:"top_users"` +} + +// GetLogsAnalytics 获取 ClickHouse 访问日志图表聚合指标 +// @Summary 获取访问日志分析数据 +// @Description 聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,ClickHouse 未启用时报错) +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=logs.logsAnalyticsResponse} "分析统计数据" +// @Failure 400 {object} util.ResponseAny "ClickHouse 未启用" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Router /api/v1/admin/logs/analytics [get] +func GetLogsAnalytics(c *gin.Context) { + // 1. 检查 ClickHouse 是否启用 + if !config.Config.ClickHouse.Enabled || db.ChConn == nil { + c.JSON(http.StatusBadRequest, util.Err("ClickHouse 存储服务未启用,无法获取分析数据")) + return + } + + // 7 天前 00:00:00 + startTime := time.Now().AddDate(0, 0, -6).Truncate(24 * time.Hour) + + // 2. 查询 7 天访问趋势 + trendRows, err := db.ChConn.Query(c.Request.Context(), ` + SELECT toDate(created_at) as date, count() as count + FROM user_access_logs + WHERE created_at >= ? + GROUP BY date + ORDER BY date ASC + `, startTime) + + trendMap := make(map[string]uint64) + // 初始化最近 7 天的数据为 0,防止某天没有访问数据时导致日期断裂 + for i := 0; i < 7; i++ { + dStr := time.Now().AddDate(0, 0, -i).Format("2006-01-02") + trendMap[dStr] = 0 + } + + if err == nil { + defer trendRows.Close() + for trendRows.Next() { + var dt time.Time + var cnt uint64 + if errScan := trendRows.Scan(&dt, &cnt); errScan == nil { + dStr := dt.Format("2006-01-02") + trendMap[dStr] = cnt + } + } + } + + var trendList []trendItem + for i := 6; i >= 0; i-- { + dStr := time.Now().AddDate(0, 0, -i).Format("2006-01-02") + trendList = append(trendList, trendItem{ + Date: dStr, + Count: trendMap[dStr], + }) + } + + // 3. 查询浏览器分布排行 + uaRows, err := db.ChConn.Query(c.Request.Context(), ` + SELECT user_agent, count() as count + FROM user_access_logs + WHERE created_at >= ? + GROUP BY user_agent + `, startTime) + + browserCounts := make(map[string]uint64) + if err == nil { + defer uaRows.Close() + for uaRows.Next() { + var ua string + var cnt uint64 + if errScan := uaRows.Scan(&ua, &cnt); errScan == nil { + browser := parseBrowserName(ua) + browserCounts[browser] += cnt + } + } + } + + var browserList []browserItem + for b, cnt := range browserCounts { + browserList = append(browserList, browserItem{ + Browser: b, + Count: cnt, + }) + } + + // 排序:按访问次数降序 + sort.Slice(browserList, func(i, j int) bool { + return browserList[i].Count > browserList[j].Count + }) + + // 4. 查询活跃用户 Top 10 (user_id > 0 代表已登录用户) + userRows, err := db.ChConn.Query(c.Request.Context(), ` + SELECT user_id, count() as count + FROM user_access_logs + WHERE created_at >= ? AND user_id > 0 + GROUP BY user_id + ORDER BY count DESC + LIMIT 10 + `, startTime) + + var topUsers []topUserItem + var userIDs []uint64 + userCountMap := make(map[uint64]uint64) + + if err == nil { + defer userRows.Close() + for userRows.Next() { + var uid uint64 + var cnt uint64 + if errScan := userRows.Scan(&uid, &cnt); errScan == nil { + userIDs = append(userIDs, uid) + userCountMap[uid] = cnt + } + } + } + + // 反查 Postgres 补全活跃用户的用户名和昵称 + userProfileMap := make(map[uint64]struct { + Username string + Nickname string + }) + + if len(userIDs) > 0 { + var users []model.User + if errProfile := db.DB(c.Request.Context()).Where("id IN ?", userIDs).Find(&users).Error; errProfile == nil { + for _, u := range users { + userProfileMap[u.ID] = struct { + Username string + Nickname string + }{ + Username: u.Username, + Nickname: u.Nickname, + } + } + } + } + + for _, uid := range userIDs { + profile := userProfileMap[uid] + topUsers = append(topUsers, topUserItem{ + UserID: uid, + Username: profile.Username, + Nickname: profile.Nickname, + Count: userCountMap[uid], + }) + } + + c.JSON(http.StatusOK, util.OK(logsAnalyticsResponse{ + Trend: trendList, + Browsers: browserList, + TopUsers: topUsers, + })) +} + +// parseBrowserName 简易的 User-Agent 浏览器类型识别 +func parseBrowserName(ua string) string { + uaLower := strings.ToLower(ua) + if strings.Contains(uaLower, "micromessenger") { + return "WeChat" + } + if strings.Contains(uaLower, "postman") { + return "Postman" + } + if strings.Contains(uaLower, "edg/") || strings.Contains(uaLower, "edge") { + return "Edge" + } + if strings.Contains(uaLower, "firefox") { + return "Firefox" + } + if strings.Contains(uaLower, "chrome") { + return "Chrome" + } + if strings.Contains(uaLower, "safari") { + return "Safari" + } + return "Other" +} diff --git a/internal/apps/risk_control/middleware.go b/internal/apps/risk_control/middleware.go new file mode 100644 index 00000000..8b75bda1 --- /dev/null +++ b/internal/apps/risk_control/middleware.go @@ -0,0 +1,91 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package risk_control + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/Rain-kl/Wavelet/internal/apps/oauth" + "github.com/Rain-kl/Wavelet/internal/config" + "github.com/Rain-kl/Wavelet/internal/db/idgen" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/util" + "github.com/gin-gonic/gin" +) + +// RiskControlMiddleware 全局日志采集中间件 +func RiskControlMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 如果未启用 ClickHouse,直接放行 + if !config.Config.ClickHouse.Enabled { + c.Next() + return + } + + // 1. 限流背压检测(检测本地缓冲队列是否已满) + if IsBufferFull() { + c.AbortWithStatusJSON(http.StatusTooManyRequests, util.Err("系统繁忙,请稍后再试")) + return + } + + start := time.Now() + + // 2. 执行后续请求(穿过业务处理和认证中间件) + c.Next() + + // 3. 后置身份检查:仅记录通过认证的请求 + userObj, exists := util.GetFromContext[*model.User](c, oauth.UserObjKey) + if !exists || userObj == nil { + return + } + + // 4. 计算耗时并异步推送到缓冲队列 + latency := time.Since(start).Milliseconds() + + var headersStr string + if c.Request.Header != nil { + // 克隆 Header,避免污染原 HTTP 请求的 Header 对象 + clonedHeaders := make(http.Header) + for k, v := range c.Request.Header { + clonedHeaders[k] = v + } + clonedHeaders.Del("Cookie") + + if headersBytes, err := json.Marshal(clonedHeaders); err == nil { + headersStr = string(headersBytes) + } + } + + logItem := &UserAccessLog{ + ID: idgen.NextUint64ID(), + UserID: userObj.ID, // 直接从 Context 获取已登录用户ID,避免数据库查询 + Path: c.Request.URL.Path, + Method: c.Request.Method, + IP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + Headers: headersStr, + Status: int32(c.Writer.Status()), + Latency: latency, + CreatedAt: time.Now(), + } + + // 非阻塞地推入缓存队列 + QueueAccessLog(logItem) + } +} diff --git a/internal/apps/risk_control/middleware_test.go b/internal/apps/risk_control/middleware_test.go new file mode 100644 index 00000000..7dfa68dd --- /dev/null +++ b/internal/apps/risk_control/middleware_test.go @@ -0,0 +1,159 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package risk_control + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Rain-kl/Wavelet/internal/apps/oauth" + "github.com/Rain-kl/Wavelet/internal/config" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/util" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestRiskControlMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("ClickHouse disabled", func(t *testing.T) { + config.Config.ClickHouse.Enabled = false + defer func() { config.Config.ClickHouse.Enabled = false }() + + r := gin.New() + r.Use(RiskControlMiddleware()) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "ok", w.Body.String()) + }) + + t.Run("ClickHouse enabled - Normal Authenticated Request", func(t *testing.T) { + config.Config.ClickHouse.Enabled = true + logChan = make(chan *UserAccessLog, defaultQueueSize) + defer func() { + config.Config.ClickHouse.Enabled = false + logChan = nil + }() + + r := gin.New() + r.Use(func(c *gin.Context) { + // Mock authentication middleware placing user in context + user := &model.User{ID: 12345} + util.SetToContext(c, oauth.UserObjKey, user) + c.Next() + }) + r.Use(RiskControlMiddleware()) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("X-Test-Header", "hello") + req.Header.Set("Cookie", "session_id=abcdef123456") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "ok", w.Body.String()) + + // Verify log is enqueued + select { + case logItem := <-logChan: + assert.Equal(t, uint64(12345), logItem.UserID) + assert.Equal(t, "/test", logItem.Path) + assert.Equal(t, http.MethodGet, logItem.Method) + assert.Equal(t, int32(http.StatusOK), logItem.Status) + assert.NotEmpty(t, logItem.Headers) + assert.Contains(t, logItem.Headers, "X-Test-Header") + assert.NotContains(t, logItem.Headers, "Cookie") + case <-time.After(100 * time.Millisecond): + t.Fatal("expected log item in logChan, but got none") + } + }) + + t.Run("ClickHouse enabled - Unauthenticated Request", func(t *testing.T) { + config.Config.ClickHouse.Enabled = true + logChan = make(chan *UserAccessLog, defaultQueueSize) + defer func() { + config.Config.ClickHouse.Enabled = false + logChan = nil + }() + + r := gin.New() + r.Use(RiskControlMiddleware()) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "ok", w.Body.String()) + + // Verify no log is enqueued + select { + case <-logChan: + t.Fatal("expected no log item for unauthenticated request") + case <-time.After(50 * time.Millisecond): + // Success + } + }) + + t.Run("ClickHouse enabled - Buffer Full Rate Limiting", func(t *testing.T) { + config.Config.ClickHouse.Enabled = true + logChan = make(chan *UserAccessLog, 2) // small capacity for quick fill + defer func() { + config.Config.ClickHouse.Enabled = false + logChan = nil + }() + + // fill logChan up to cap to simulate buffer full + for len(logChan) < cap(logChan) { + logChan <- &UserAccessLog{} + } + + r := gin.New() + r.Use(RiskControlMiddleware()) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusTooManyRequests, w.Code) + + var resp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.Contains(t, resp["error_msg"], "系统繁忙") + }) +} diff --git a/internal/apps/risk_control/model.go b/internal/apps/risk_control/model.go new file mode 100644 index 00000000..13a0701a --- /dev/null +++ b/internal/apps/risk_control/model.go @@ -0,0 +1,148 @@ +/* +Copyright 2026 Arctel.net + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package risk_control + +import ( + "context" + "time" + + "github.com/Rain-kl/Wavelet/internal/config" + "github.com/Rain-kl/Wavelet/internal/db" + "github.com/Rain-kl/Wavelet/internal/logger" +) + +// UserAccessLog 用户访问记录 +type UserAccessLog struct { + ID uint64 `json:"id,string"` + UserID uint64 `json:"user_id,string"` + Path string `json:"path"` + Method string `json:"method"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + Headers string `json:"headers"` + Status int32 `json:"status"` + Latency int64 `json:"latency"` // 耗时毫秒 + CreatedAt time.Time `json:"created_at"` +} + +var ( + logChan chan *UserAccessLog +) + +const ( + defaultQueueSize = 10000 + maxBatchSize = 1000 + flushInterval = 1 * time.Second +) + +// InitLogWriter 初始化日志写入通道和后台写入协程 +func InitLogWriter() { + if !config.Config.ClickHouse.Enabled { + return + } + + logChan = make(chan *UserAccessLog, defaultQueueSize) + go startBatchWorker() +} + +// IsBufferFull 检查当前本地缓冲队列是否已满 +// 如果没有启用 ClickHouse,默认返回 false,不触发限流 +func IsBufferFull() bool { + if !config.Config.ClickHouse.Enabled || logChan == nil { + return false + } + return len(logChan) >= cap(logChan) +} + +// QueueAccessLog 异步非阻塞地将日志推入缓冲队列 +func QueueAccessLog(logItem *UserAccessLog) { + if !config.Config.ClickHouse.Enabled || logChan == nil { + return + } + + select { + case logChan <- logItem: + default: + // 如果在极端并发下仍然写满了,这里做非阻塞丢弃,防止卡死 + logger.WarnF(context.Background(), "[RiskControl] Log queue full, dropping log item for path: %s", logItem.Path) + } +} + +// startBatchWorker 后台批量写入 ClickHouse 的工作协程 +func startBatchWorker() { + ticker := time.NewTicker(flushInterval) + defer ticker.Stop() + + var batch []*UserAccessLog + + flush := func() { + if len(batch) == 0 { + return + } + if db.ChConn == nil { + batch = nil + return + } + + ctx := context.Background() + b, err := db.ChConn.PrepareBatch(ctx, "INSERT INTO user_access_logs (id, user_id, path, method, ip, user_agent, headers, status, latency, created_at)") + if err != nil { + logger.ErrorF(ctx, "[RiskControl] Prepare ClickHouse batch failed: %v", err) + batch = nil + return + } + + for _, item := range batch { + err = b.Append( + item.ID, + item.UserID, + item.Path, + item.Method, + item.IP, + item.UserAgent, + item.Headers, + item.Status, + item.Latency, + item.CreatedAt, + ) + if err != nil { + logger.ErrorF(ctx, "[RiskControl] Append item to ClickHouse batch failed: %v", err) + } + } + + if err := b.Send(); err != nil { + logger.ErrorF(ctx, "[RiskControl] Send ClickHouse batch failed: %v", err) + } + batch = nil + } + + for { + select { + case item, ok := <-logChan: + if !ok { + flush() + return + } + batch = append(batch, item) + if len(batch) >= maxBatchSize { + flush() + } + case <-ticker.C: + flush() + } + } +} diff --git a/internal/router/router.go b/internal/router/router.go index bab4410c..b55f93d4 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -38,6 +38,7 @@ import ( capApp "github.com/Rain-kl/Wavelet/internal/apps/cap" publicconfig "github.com/Rain-kl/Wavelet/internal/apps/config" "github.com/Rain-kl/Wavelet/internal/apps/health" + "github.com/Rain-kl/Wavelet/internal/apps/risk_control" "github.com/Rain-kl/Wavelet/internal/apps/upload" "github.com/Rain-kl/Wavelet/internal/apps/user" "github.com/Rain-kl/Wavelet/internal/model" @@ -63,6 +64,9 @@ func Serve() { gin.SetMode(gin.ReleaseMode) } + // 初始化 ClickHouse 异步日志写入器 + risk_control.InitLogWriter() + // 初始化路由 r := gin.New() r.Use(gin.Recovery()) @@ -100,7 +104,7 @@ func Serve() { r.Use(sessions.Sessions(config.Config.App.SessionCookieName, sessionStore)) // 补充中间件 - r.Use(otelgin.Middleware(config.Config.App.AppName), loggerMiddleware()) + r.Use(otelgin.Middleware(config.Config.App.AppName), loggerMiddleware(), risk_control.RiskControlMiddleware()) // Serve files by ID r.GET("/f/:id", upload.ServeFileByID) @@ -193,6 +197,8 @@ func Serve() { // System logs adminRouter.GET("/logs", admin_logs.GetLogs) + adminRouter.GET("/logs/access", admin_logs.GetAccessLogs) + adminRouter.GET("/logs/analytics", admin_logs.GetLogsAnalytics) adminRouter.GET("/logs/ws", admin_logs.HandleLogWebSocket) // Task dispatch diff --git a/support-files/sql/create_clickhouse.sql b/support-files/sql/create_clickhouse.sql deleted file mode 100644 index dd0eb9b6..00000000 --- a/support-files/sql/create_clickhouse.sql +++ /dev/null @@ -1,45 +0,0 @@ -CREATE DATABASE IF NOT EXISTS linux_do_credit; - -USE linux_do_credit; - -CREATE TABLE IF NOT EXISTS orders -( - id UInt64, - order_name String, - merchant_order_no String, - client_id String, - payer_user_id UInt64, - payee_user_id UInt64, - amount Decimal(20, 2), - status LowCardinality(String), - type LowCardinality(String), - remark String, - payment_type LowCardinality(String), - trade_time DateTime, - expires_at DateTime, - created_at DateTime, - updated_at DateTime -) - ENGINE = ReplacingMergeTree(updated_at) - PARTITION BY toYYYYMM(created_at) - ORDER BY (created_at, id) - SETTINGS index_granularity = 8192; - --- ============================================================ --- 常用查询示例 --- ============================================================ - --- 查询单个订单(加 FINAL 获取最新版本) --- SELECT * FROM orders FINAL WHERE id = 1001; - --- 按用户统计订单 --- SELECT payer_user_id, count() AS cnt, sum(amount) AS total --- FROM orders FINAL --- WHERE status = 'success' --- GROUP BY payer_user_id; - --- 按月统计(利用分区裁剪) --- SELECT toYYYYMM(created_at) AS month, count() AS orders, sum(amount) AS volume --- FROM orders FINAL --- GROUP BY month --- ORDER BY month; diff --git a/support-files/sql/create_clickhouse_risk.sql b/support-files/sql/create_clickhouse_risk.sql new file mode 100644 index 00000000..8fb811a7 --- /dev/null +++ b/support-files/sql/create_clickhouse_risk.sql @@ -0,0 +1,21 @@ +CREATE DATABASE IF NOT EXISTS wavelet; + +USE wavelet; + +CREATE TABLE IF NOT EXISTS user_access_logs +( + id UInt64, + user_id UInt64, + path String, + method String, + ip String, + user_agent String, + headers String, + status Int32, + latency Int64, + created_at DateTime +) +ENGINE = MergeTree() +PARTITION BY toYYYYMM(created_at) +ORDER BY (created_at, ip, user_id) +SETTINGS index_granularity = 8192;