| {p.id} |
diff --git a/frontend/src/pages/admin/AdminLinksPage.tsx b/frontend/src/pages/admin/AdminLinksPage.tsx
index cfad162..5993652 100644
--- a/frontend/src/pages/admin/AdminLinksPage.tsx
+++ b/frontend/src/pages/admin/AdminLinksPage.tsx
@@ -178,9 +178,12 @@ export default function AdminLinksPage() {
aside_show_tag_cloud: false,
aside_show_recent_comments: false,
aside_show_friend_links: true,
+ aside_show_showcase: false,
aside_widgets: loadedAsideWidgets,
nav_show_friend_links: true,
footer_show_friend_links: true,
+ nav_show_showcase: false,
+ footer_show_showcase: false,
feed_list_style: 'title',
permalink_enabled: false,
permalink_ext: 'html',
@@ -350,14 +353,18 @@ export default function AdminLinksPage() {
aside_show_tag_cloud: false,
aside_show_recent_comments: false,
aside_show_friend_links: nextFlags.aside,
+ aside_show_showcase: false,
aside_widgets: normalizeAsideWidgets([
{ id: 'tag_cloud', enabled: false },
{ id: 'recent_comments', enabled: false },
{ id: 'recent_users', enabled: false },
{ id: 'friend_links', enabled: nextFlags.aside },
+ { id: 'showcase', enabled: false },
]),
nav_show_friend_links: nextFlags.nav,
footer_show_friend_links: nextFlags.footer,
+ nav_show_showcase: false,
+ footer_show_showcase: false,
feed_list_style: 'title',
permalink_enabled: false,
permalink_ext: 'html',
diff --git a/frontend/src/pages/admin/AdminMonitorPage.tsx b/frontend/src/pages/admin/AdminMonitorPage.tsx
new file mode 100644
index 0000000..28406e4
--- /dev/null
+++ b/frontend/src/pages/admin/AdminMonitorPage.tsx
@@ -0,0 +1,707 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { Activity, AlertTriangle } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Switch } from '@/components/ui/switch';
+import { notify } from '@/lib/notify';
+import { cn } from '@/lib/utils';
+import { api } from '../../api/client';
+import type {
+ MonitorASNItem,
+ MonitorCityItem,
+ MonitorConfig,
+ MonitorGeoResult,
+ MonitorLogItem,
+ MonitorOverview,
+ MonitorRealtime,
+ MonitorStatItem,
+} from '../../api/types';
+import { useAdminGuard } from '../../layouts/AdminLayout';
+import { formatTime } from '../../utils/content';
+import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
+import MonitorWorldMap from '../../components/admin/MonitorWorldMap';
+import MonitorChinaMap from '../../components/admin/MonitorChinaMap';
+import { countryLabelZh } from '../../components/admin/monitorMapUtils';
+
+type TabKey = 'overview' | 'stats' | 'logs' | 'settings';
+type StatsDim = 'url' | 'referer' | 'browser' | 'os' | 'device' | 'status';
+type StatsRange = '1d' | '7d' | '30d' | '90d';
+type MapMode = 'world' | 'china';
+type RankMode = 'city' | 'asn';
+
+function countryLabel(code: string) {
+ return countryLabelZh(code);
+}
+
+function formatBytes(n: number) {
+ if (!n || n < 0) return '0 B';
+ const u = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let v = n;
+ let i = 0;
+ while (v >= 1024 && i < u.length - 1) {
+ v /= 1024;
+ i++;
+ }
+ return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}`;
+}
+
+function formatNum(n: number) {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ if (n >= 10_000) return `${(n / 1000).toFixed(1)}k`;
+ return String(n ?? 0);
+}
+
+function MiniSparkline({ series }: { series: { count: number }[] }) {
+ const max = Math.max(1, ...series.map((p) => p.count));
+ const w = 240;
+ const h = 48;
+ const pts = series.map((p, i) => {
+ const x = series.length <= 1 ? 0 : (i / (series.length - 1)) * w;
+ const y = h - (p.count / max) * (h - 4) - 2;
+ return `${x},${y}`;
+ }).join(' ');
+ return (
+
+ );
+}
+
+const CHINA_CODES = new Set(['CN', 'HK', 'TW', 'MO']);
+
+function geoCountries(geo: MonitorGeoResult | null) {
+ return geo?.countries || [];
+}
+
+function GeoRankTable({
+ cities,
+ asns,
+ mode,
+ mapMode,
+ rankMode,
+ onRankMode,
+}: {
+ cities: MonitorCityItem[];
+ asns: MonitorASNItem[];
+ mode: MapMode;
+ rankMode: RankMode;
+ onRankMode: (m: RankMode) => void;
+}) {
+ const cityRows = mode === 'china'
+ ? cities.filter((i) => CHINA_CODES.has((i.country || '').toUpperCase()))
+ : cities;
+ const rows = rankMode === 'city' ? cityRows : asns;
+ const emptyHint = rankMode === 'city'
+ ? '放置 IP2Location DB3 BIN 后可见城市排行'
+ : '放置 GeoLite2-ASN.mmdb 后可见运营商排行';
+
+ return (
+
+
+
+
+
+ {rows.length === 0 ? (
+
+ ) : (
+
+
+
+ | {rankMode === 'city' ? '城市' : '运营商'} |
+ 数量 |
+
+
+
+ {rankMode === 'city'
+ ? cityRows.slice(0, 12).map((item, idx) => (
+
+ |
+ {item.city}
+ {item.region ? · {item.region} : null}
+ |
+ {formatNum(item.count)} |
+
+ ))
+ : asns.slice(0, 12).map((item) => (
+
+ |
+ {item.as_org || `AS${item.asn}`}
+ · AS{item.asn}
+ |
+ {formatNum(item.count)} |
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function parseMonitorTab(raw: string | null): TabKey {
+ if (raw === 'stats' || raw === 'logs' || raw === 'settings' || raw === 'overview') {
+ return raw;
+ }
+ return 'overview';
+}
+
+export default function AdminMonitorPage() {
+ const { ready } = useAdminGuard();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [tab, setTab] = useState(() => parseMonitorTab(searchParams.get('tab')));
+ const [overview, setOverview] = useState(null);
+ const [geo, setGeo] = useState(null);
+ const [realtime, setRealtime] = useState(null);
+ const [mapMode, setMapMode] = useState('world');
+ const [rankMode, setRankMode] = useState('city');
+ const [loading, setLoading] = useState(true);
+
+ const [statsDim, setStatsDim] = useState('url');
+ const [statsRange, setStatsRange] = useState('30d');
+ const [statsItems, setStatsItems] = useState([]);
+ const [statsLoading, setStatsLoading] = useState(false);
+
+ const [logs, setLogs] = useState([]);
+ const [logsTotal, setLogsTotal] = useState(0);
+ const [logsPage, setLogsPage] = useState(1);
+ const [logMethod, setLogMethod] = useState('');
+ const [logPath, setLogPath] = useState('');
+ const [logStatus, setLogStatus] = useState('');
+ const [logIP, setLogIP] = useState('');
+ const [logsLoading, setLogsLoading] = useState(false);
+
+ const [settings, setSettings] = useState(null);
+ const [excludeText, setExcludeText] = useState('');
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ const next = parseMonitorTab(searchParams.get('tab'));
+ setTab((prev) => (prev === next ? prev : next));
+ }, [searchParams]);
+
+ const selectTab = (key: TabKey) => {
+ setTab(key);
+ const next = new URLSearchParams(searchParams);
+ if (key === 'overview') next.delete('tab');
+ else next.set('tab', key);
+ setSearchParams(next, { replace: true });
+ };
+
+ const loadOverview = useCallback(async () => {
+ const [ov, g, rt] = await Promise.all([
+ api.adminMonitorOverview(),
+ api.adminMonitorGeo('30d'),
+ api.adminMonitorRealtime(),
+ ]);
+ setOverview(ov);
+ setGeo(g);
+ setRealtime(rt);
+ }, []);
+
+ useEffect(() => {
+ if (!ready) return;
+ setLoading(true);
+ loadOverview()
+ .catch(() => notify.error('加载监控概览失败'))
+ .finally(() => setLoading(false));
+ }, [ready, loadOverview]);
+
+ // 概览页实时轮询
+ useEffect(() => {
+ if (!ready || tab !== 'overview') return;
+ const id = window.setInterval(() => {
+ api.adminMonitorRealtime().then(setRealtime).catch(() => {});
+ api.adminMonitorOverview().then(setOverview).catch(() => {});
+ }, 5000);
+ return () => window.clearInterval(id);
+ }, [ready, tab]);
+
+ useEffect(() => {
+ if (!ready || tab !== 'stats') return;
+ setStatsLoading(true);
+ api.adminMonitorStats(statsDim, statsRange)
+ .then((r) => setStatsItems(r.items || []))
+ .catch(() => notify.error('加载统计失败'))
+ .finally(() => setStatsLoading(false));
+ }, [ready, tab, statsDim, statsRange]);
+
+ const loadLogs = useCallback(async (page = 1) => {
+ setLogsLoading(true);
+ try {
+ const r = await api.adminMonitorLogs({
+ page,
+ size: 20,
+ method: logMethod || undefined,
+ path: logPath || undefined,
+ status: logStatus || undefined,
+ ip: logIP || undefined,
+ });
+ setLogs(r.items || []);
+ setLogsTotal(r.total || 0);
+ setLogsPage(r.page || page);
+ } catch {
+ notify.error('加载请求日志失败');
+ } finally {
+ setLogsLoading(false);
+ }
+ }, [logMethod, logPath, logStatus, logIP]);
+
+ useEffect(() => {
+ if (!ready || tab !== 'logs') return;
+ loadLogs(1);
+ }, [ready, tab, loadLogs]);
+
+ useEffect(() => {
+ if (!ready || tab !== 'settings') return;
+ api.adminMonitorSettings()
+ .then((cfg) => {
+ setSettings(cfg);
+ setExcludeText((cfg.exclude_rules || []).join('\n'));
+ })
+ .catch(() => notify.error('加载设置失败'));
+ }, [ready, tab]);
+
+ const enabled = overview?.enabled ?? settings?.enabled ?? false;
+
+ const metrics = useMemo(() => {
+ if (!overview) return [];
+ return [
+ { label: '浏览量', value: formatNum(overview.pageviews) },
+ { label: '访客数', value: formatNum(overview.visitors) },
+ { label: '独立 IP', value: formatNum(overview.unique_ips) },
+ { label: '流量', value: formatBytes(overview.traffic) },
+ { label: '蜘蛛', value: formatNum(overview.bots) },
+ { label: '请求数', value: formatNum(overview.requests) },
+ { label: '4xx', value: formatNum(overview.status_4xx) },
+ { label: '5xx', value: formatNum(overview.status_5xx) },
+ ];
+ }, [overview]);
+
+ const saveSettings = async () => {
+ if (!settings) return;
+ setSaving(true);
+ try {
+ const rules = excludeText.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
+ const r = await api.adminUpdateMonitorSettings({
+ ...settings,
+ exclude_rules: rules,
+ });
+ setSettings(r.monitor);
+ setExcludeText((r.monitor.exclude_rules || []).join('\n'));
+ invalidateForumLimitsCache();
+ notify.success(r.message);
+ const ov = await api.adminMonitorOverview();
+ setOverview(ov);
+ } catch (e: unknown) {
+ notify.error(e instanceof Error ? e.message : '保存失败');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ if (!ready || (loading && tab === 'overview')) {
+ return ;
+ }
+
+ const tabs: { key: TabKey; label: string }[] = [
+ { key: 'overview', label: '概览' },
+ { key: 'stats', label: '访问统计' },
+ { key: 'logs', label: '请求日志' },
+ { key: 'settings', label: '设置' },
+ ];
+
+ const dims: { key: StatsDim; label: string }[] = [
+ { key: 'url', label: 'URL' },
+ { key: 'referer', label: '来源' },
+ { key: 'browser', label: '浏览器' },
+ { key: 'os', label: '系统' },
+ { key: 'device', label: '设备' },
+ { key: 'status', label: '状态码' },
+ ];
+
+ const ranges: { key: StatsRange; label: string }[] = [
+ { key: '1d', label: '今日' },
+ { key: '7d', label: '7 日' },
+ { key: '30d', label: '30 日' },
+ { key: '90d', label: '90 日' },
+ ];
+
+ return (
+
+
+
+
+
+ 网站监控
+
+
+ 浏览量/访客来自前台路由 pageview;请求数与日志来自服务端访问采集(含 /api)
+
+
+
+
+
+ {tabs.map((t) => (
+
+ ))}
+
+
+ {!enabled && tab !== 'settings' && (
+
+
+ 访问采集尚未开启,指标可能为空。
+
+
+ )}
+
+ {tab === 'overview' && overview && (
+ <>
+
+
+
+ 今日状态
+
+
+ {metrics.map((m) => (
+
+ {m.label}
+ {m.value}
+
+ ))}
+
+
+
+
+
+
+
+
+ 访客地图(30 日)
+
+
+
+
+
+
+
+
+ {mapMode === 'world' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ 实时请求数(1 分钟)
+
+ {formatNum(realtime?.requests_1m || 0)}
+
+
+
+
+ 实时流量(1 分钟)
+
+ {formatBytes(realtime?.traffic_1m || 0)}
+ 近 1 小时请求
+
+
+
+
+ >
+ )}
+
+ {tab === 'stats' && (
+
+
+
+ {dims.map((d) => (
+
+ ))}
+
+
+ {ranges.map((r) => (
+
+ ))}
+
+
+
+ {statsLoading ? (
+
+ ) : statsItems.length === 0 ? (
+ 暂无统计数据
+ ) : (
+
+
+
+
+ | # |
+ 名称 |
+ 数量 |
+
+
+
+ {statsItems.map((item, idx) => (
+
+ | {idx + 1} |
+ {item.key} |
+ {formatNum(item.count)} |
+
+ ))}
+
+
+
+ )}
+
+
+ )}
+
+ {tab === 'logs' && (
+
+
+ setLogMethod(e.target.value)} placeholder="方法 GET" aria-label="方法" style={{ width: 100 }} />
+ setLogPath(e.target.value)} placeholder="路径包含…" aria-label="路径" />
+ setLogStatus(e.target.value)} placeholder="状态码" aria-label="状态码" style={{ width: 90 }} />
+ setLogIP(e.target.value)} placeholder="IP 包含…" aria-label="IP" style={{ width: 140 }} />
+
+
+
+ {logsLoading ? (
+
+ ) : logs.length === 0 ? (
+ 暂无请求日志
+ ) : (
+
+
+
+
+ | 时间 |
+ 方法 |
+ 路径 |
+ 状态 |
+ IP |
+ 耗时 |
+ 地区 |
+
+
+
+ {logs.map((row) => {
+ const geoBits = [
+ row.country ? countryLabel(row.country) : '',
+ row.city || '',
+ row.as_org || '',
+ ].filter(Boolean);
+ return (
+
+ | {formatTime(row.created_at)} |
+ {row.method} |
+ {row.path} |
+ {row.status} |
+
+ {row.ip || '—'}
+ {row.is_bot ? ' · bot' : ''}
+ {(row.city || row.as_org) ? (
+
+ {[row.city, row.as_org].filter(Boolean).join(' · ')}
+
+ ) : null}
+ |
+ {row.duration_ms}ms |
+ {row.country ? countryLabel(row.country) : '—'} |
+
+ );
+ })}
+
+
+
+ )}
+ {logsTotal > 20 && (
+
+
+ 第 {logsPage} 页 / 共 {logsTotal} 条
+
+
+ )}
+
+
+ )}
+
+ {tab === 'settings' && settings && (
+
+ 采集设置
+
+
+
+
+
+
+
+
+
+
+
+
+ GeoIP(地理与运营商)
+
+ 国家/省/市由 IP2Location DB3 BIN 解析;ASN 由 GeoLite2-ASN.mmdb 解析(Docker 镜像不内置,需放入数据目录):
+
+
+ -
+ IPv4 BIN:
{settings.ip2location_v4_path || 'data/IP2LOCATION-LITE-DB3.BIN'}
+ {' · '}{settings.ip2location_v4_available ? '已加载' : '未检测到'}
+
+ -
+ IPv6 BIN:
{settings.ip2location_v6_path || 'data/IP2LOCATION-LITE-DB3.IPV6.BIN'}
+ {' · '}{settings.ip2location_v6_available ? '已加载' : '未检测到'}
+
+ -
+ ASN:
{settings.geoip_asn_path || 'data/GeoLite2-ASN.mmdb'}
+ {' · '}{settings.geoip_asn_available ? '已加载' : '未检测到'}
+
+ -
+ Country 兜底:
{settings.geoip_country_path || 'data/GeoLite2-Country.mmdb'}
+ {' · '}{settings.geoip_country_available ? '已加载' : '未检测到(可选)'}
+
+
+
+ 请求日志目录:{settings.access_log_dir || 'data/logs/access'}
+ (按日 JSONL 文件,删除过期文件即可释放磁盘)
+
+
+ CDN 国家头(如 CF-IPCountry)仅在本地库无国家码时补全。管理端展示完整客户端 IP。
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/admin/AdminSettingsPage.tsx b/frontend/src/pages/admin/AdminSettingsPage.tsx
index 98ecf55..529a85b 100644
--- a/frontend/src/pages/admin/AdminSettingsPage.tsx
+++ b/frontend/src/pages/admin/AdminSettingsPage.tsx
@@ -298,9 +298,12 @@ export default function AdminSettingsPage() {
aside_show_tag_cloud: false,
aside_show_recent_comments: false,
aside_show_friend_links: true,
+ aside_show_showcase: false,
aside_widgets: loadedAsideWidgets,
nav_show_friend_links: true,
footer_show_friend_links: true,
+ nav_show_showcase: false,
+ footer_show_showcase: false,
feed_list_style: 'title',
permalink_enabled: false,
permalink_ext: 'html',
@@ -1676,7 +1679,7 @@ export default function AdminSettingsPage() {
数据备份
- 导出当前 SQLite 数据库副本,便于迁移或灾难恢复。
+ 导出当前主库 SQLite 副本,便于迁移或灾难恢复。不含 monitor.db 浏览量。
文件名:jiang13_backup_YYYYMMDD_HHMMSS.db
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 1b2bc05..f6b4688 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -7210,6 +7210,7 @@ a.waline-comment-author:hover {
.widget-card-icon--hot { color: #e74c3c; }
.widget-card-icon--notice { color: #3498db; }
.widget-card-icon--links { color: #2d6a4f; }
+.widget-card-icon--showcase { color: #1d4ed8; }
.widget-card-icon--users { color: #8b5cf6; }
.widget-card-head--split {
@@ -11199,6 +11200,216 @@ button.profile-stat:hover strong {
.admin-page-head { margin-bottom: 20px; }
.admin-page-head h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
.admin-page-head p { font-size: 13px; color: hsl(var(--muted-foreground)); }
+
+/* ========== 仪表盘 bento ========== */
+.admin-dash-head-title {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-bottom: 4px;
+}
+.admin-dash-head-title h1 { margin-bottom: 0; }
+.admin-dash-section-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+.admin-dash-section-label h2 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+}
+.admin-dash-bento {
+ display: grid;
+ grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
+ gap: 16px;
+ margin-bottom: 20px;
+ align-items: stretch;
+}
+.admin-dash-traffic {
+ border: 1px solid var(--j13-border);
+ border-radius: 12px;
+ background: hsl(var(--card));
+ padding: 18px 20px 20px;
+ display: flex;
+ flex-direction: column;
+ min-height: 220px;
+}
+.admin-dash-traffic-top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+.admin-dash-traffic-top .admin-dash-section-label { margin-bottom: 0; }
+.admin-dash-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 13px;
+ white-space: nowrap;
+}
+.admin-dash-traffic-pv {
+ font-size: 48px;
+ font-weight: 700;
+ color: var(--j13-green);
+ font-variant-numeric: tabular-nums;
+ line-height: 1.1;
+ margin-top: 8px;
+}
+.admin-dash-traffic-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 16px;
+ margin-top: 8px;
+ font-size: 13px;
+ color: hsl(var(--muted-foreground));
+}
+.admin-dash-traffic-uv {
+ color: hsl(var(--foreground));
+ font-weight: 500;
+}
+.admin-dash-traffic-trend {
+ margin-top: 10px;
+ font-size: 13px;
+ font-weight: 600;
+ color: hsl(var(--muted-foreground));
+}
+.admin-dash-traffic-trend.is-up { color: var(--j13-green); }
+.admin-dash-traffic-trend.is-down { color: hsl(24 90% 42%); }
+.admin-dash-traffic-total {
+ margin-top: auto;
+ padding-top: 16px;
+ font-size: 12px;
+ color: hsl(var(--muted-foreground));
+}
+.admin-dash-traffic-hint { opacity: 0.85; margin-left: 4px; }
+.admin-dash-traffic-empty {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 8px;
+ font-size: 13px;
+ color: hsl(var(--muted-foreground));
+ padding: 12px 0;
+}
+
+.admin-dash-queue {
+ border: 1px solid var(--j13-border);
+ border-radius: 12px;
+ background: hsl(var(--card));
+ padding: 18px 20px 20px;
+ display: flex;
+ flex-direction: column;
+ min-height: 220px;
+}
+.admin-dash-queue-head { margin-bottom: 12px; }
+.admin-dash-queue-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ flex: 1;
+}
+.admin-dash-queue-card {
+ display: flex;
+ flex-direction: column;
+ text-align: left;
+ padding: 12px 14px;
+ border-radius: 10px;
+ border: 1px solid var(--j13-border);
+ background: hsl(var(--background));
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s;
+ font-family: inherit;
+}
+.admin-dash-queue-card:hover {
+ border-color: color-mix(in srgb, var(--j13-green) 45%, var(--j13-border));
+ background: var(--j13-green-bg);
+}
+.admin-dash-queue-card.has-work {
+ border-color: hsl(24 80% 55% / 0.45);
+ background: hsl(24 90% 48% / 0.06);
+}
+.admin-dash-queue-card.has-work:hover {
+ border-color: hsl(24 80% 50%);
+ background: hsl(24 90% 48% / 0.1);
+}
+.admin-dash-queue-card-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ color: hsl(var(--muted-foreground));
+ margin-bottom: 6px;
+}
+.admin-dash-queue-count {
+ font-size: 22px;
+ font-weight: 700;
+ color: hsl(var(--foreground));
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+}
+.admin-dash-queue-card.has-work .admin-dash-queue-count {
+ color: hsl(24 90% 40%);
+}
+.dark .admin-dash-queue-card.has-work .admin-dash-queue-count {
+ color: hsl(24 95% 68%);
+}
+.admin-dash-queue-label {
+ font-size: 13px;
+ font-weight: 600;
+ color: hsl(var(--foreground));
+}
+.admin-dash-queue-hint {
+ font-size: 12px;
+ color: hsl(var(--muted-foreground));
+ margin-top: 2px;
+}
+
+.admin-dash-scale {
+ margin-bottom: 20px;
+}
+.admin-dash-metric-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+}
+.admin-dash-metric-card {
+ padding: 16px 18px;
+ border-radius: 10px;
+ border: 1px solid var(--j13-border);
+ background: hsl(var(--card));
+}
+.admin-dash-metric-value {
+ font-size: 24px;
+ font-weight: 700;
+ color: hsl(var(--foreground));
+ font-variant-numeric: tabular-nums;
+ line-height: 1.2;
+ margin-bottom: 6px;
+}
+.admin-dash-metric-label {
+ font-size: 12px;
+ color: hsl(var(--muted-foreground));
+}
+
+@media (max-width: 960px) {
+ .admin-dash-bento {
+ grid-template-columns: 1fr;
+ }
+ .admin-dash-traffic-pv {
+ font-size: 40px;
+ }
+}
+@media (max-width: 640px) {
+ .admin-dash-metric-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
.admin-section-label {
display: flex; align-items: center; gap: 8px;
margin-bottom: 10px; font-size: 13px; font-weight: 600;
@@ -15091,3 +15302,339 @@ button.post-poll__option,
margin: 0 4px;
}
+/* ========== 网站监控 ========== */
+.admin-monitor-banner {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-bottom: 16px;
+ padding: 10px 14px;
+ border-radius: 10px;
+ border: 1px solid color-mix(in srgb, var(--j13-green) 30%, var(--j13-border));
+ background: var(--j13-green-bg);
+ font-size: 13px;
+ color: var(--color-text-2);
+}
+
+.admin-monitor-section-bar {
+ display: inline-block;
+ width: 3px;
+ height: 14px;
+ border-radius: 2px;
+ background: var(--j13-green);
+ flex-shrink: 0;
+}
+.admin-monitor-today-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+.admin-monitor-section-title {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: hsl(var(--foreground));
+}
+
+.admin-monitor-today {
+ padding: 16px 20px 20px;
+ margin-bottom: 16px;
+}
+.admin-monitor-today-grid {
+ display: grid;
+ grid-template-columns: repeat(8, minmax(0, 1fr));
+ gap: 8px 12px;
+}
+.admin-monitor-today-item {
+ min-width: 0;
+ text-align: left;
+ padding: 4px 8px;
+}
+.admin-monitor-today-label {
+ font-size: 12px;
+ color: hsl(var(--muted-foreground));
+ margin-bottom: 6px;
+}
+.admin-monitor-today-value {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--j13-green);
+ font-variant-numeric: tabular-nums;
+ line-height: 1.2;
+ word-break: break-all;
+}
+
+.admin-monitor-main {
+ display: grid;
+ grid-template-columns: minmax(0, 1.7fr) minmax(240px, 0.75fr);
+ gap: 16px;
+ align-items: stretch;
+}
+.admin-monitor-map-panel .admin-card-head,
+.admin-monitor-map-head {
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.admin-monitor-map-body {
+ display: grid;
+ grid-template-columns: minmax(0, 1.4fr) minmax(160px, 0.7fr);
+ gap: 16px;
+ min-height: 320px;
+ padding-top: 12px;
+}
+.admin-monitor-map-visual {
+ position: relative;
+ min-height: 280px;
+ border-radius: 8px;
+ background: color-mix(in srgb, hsl(var(--muted)) 35%, hsl(var(--card)));
+ overflow: hidden;
+}
+.admin-monitor-map-side {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ max-height: 360px;
+ overflow: auto;
+}
+
+.admin-monitor-seg {
+ display: inline-flex;
+ border: 1px solid var(--j13-border);
+ border-radius: 8px;
+ overflow: hidden;
+ background: hsl(var(--background));
+}
+.admin-monitor-seg-btn {
+ height: 28px;
+ padding: 0 12px;
+ border: none;
+ background: transparent;
+ color: hsl(var(--muted-foreground));
+ font-size: 12px;
+ font-family: inherit;
+ cursor: pointer;
+}
+.admin-monitor-seg-btn + .admin-monitor-seg-btn {
+ border-left: 1px solid var(--j13-border);
+}
+.admin-monitor-seg-btn.active {
+ background: var(--j13-green);
+ color: #fff;
+ font-weight: 600;
+}
+
+.admin-monitor-svg-wrap {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ min-height: 280px;
+}
+.admin-monitor-svg-map {
+ display: block;
+ width: 100%;
+ height: 100%;
+ min-height: 280px;
+}
+.admin-monitor-svg-path {
+ cursor: default;
+ transition: fill 0.15s ease, opacity 0.15s ease;
+}
+.admin-monitor-svg-path.has-data {
+ cursor: pointer;
+}
+.admin-monitor-svg-path.has-data:hover {
+ opacity: 0.88;
+ filter: brightness(1.05);
+}
+.admin-monitor-map-tip {
+ position: absolute;
+ left: 12px;
+ bottom: 12px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 10px;
+ border-radius: 8px;
+ background: color-mix(in srgb, hsl(var(--card)) 92%, transparent);
+ border: 1px solid var(--j13-border);
+ font-size: 12px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
+ pointer-events: none;
+}
+.admin-monitor-map-tip strong {
+ font-weight: 600;
+}
+.admin-monitor-map-tip span {
+ color: var(--j13-green);
+ font-variant-numeric: tabular-nums;
+ font-weight: 600;
+}
+.admin-monitor-map-empty-overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ text-align: center;
+ font-size: 13px;
+ color: hsl(var(--muted-foreground));
+ background: color-mix(in srgb, hsl(var(--card)) 55%, transparent);
+ pointer-events: none;
+ padding: 16px;
+}
+
+.admin-monitor-map-side .admin-monitor-seg {
+ margin-bottom: 10px;
+}
+.admin-monitor-rank .admin-monitor-geo-empty {
+ margin-top: 4px;
+}
+.admin-monitor-geo-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+.admin-monitor-geo-table th,
+.admin-monitor-geo-table td {
+ padding: 8px 10px;
+ text-align: left;
+}
+.admin-monitor-geo-table th:last-child,
+.admin-monitor-geo-table td:last-child {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+.admin-monitor-geo-table thead th {
+ background: var(--j13-green);
+ color: #fff;
+ font-weight: 600;
+ font-size: 12px;
+}
+.admin-monitor-geo-table thead th:first-child {
+ border-radius: 6px 0 0 0;
+}
+.admin-monitor-geo-table thead th:last-child {
+ border-radius: 0 6px 0 0;
+}
+.admin-monitor-geo-table tbody tr:nth-child(even) {
+ background: var(--j13-green-bg);
+}
+.admin-monitor-geo-table tbody td {
+ border-bottom: 1px solid var(--j13-border);
+ color: hsl(var(--foreground));
+}
+.admin-monitor-geo-empty {
+ padding: 24px 8px;
+ text-align: center;
+ font-size: 13px;
+ color: hsl(var(--muted-foreground));
+}
+
+.admin-monitor-rt-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+.admin-monitor-rt-card {
+ padding: 16px 20px 20px;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+.admin-monitor-rt-value {
+ font-size: 40px;
+ font-weight: 700;
+ color: var(--j13-green);
+ font-variant-numeric: tabular-nums;
+ line-height: 1.15;
+ margin: 8px 0 4px;
+}
+.admin-monitor-rt-spark-label {
+ margin: 16px 0 6px;
+}
+.admin-monitor-spark {
+ display: block;
+ max-width: 100%;
+}
+.admin-monitor-muted {
+ font-size: 12px;
+ color: hsl(var(--muted-foreground));
+ line-height: 1.5;
+}
+.admin-monitor-log-filters {
+ border-bottom: 1px solid var(--j13-border);
+}
+.admin-monitor-settings {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+.admin-monitor-switch-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+.admin-monitor-field {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ font-size: 13px;
+}
+.admin-monitor-textarea {
+ width: 100%;
+ min-height: 140px;
+ padding: 10px 12px;
+ border-radius: 8px;
+ border: 1px solid var(--j13-border);
+ background: hsl(var(--background));
+ color: inherit;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 12px;
+ resize: vertical;
+}
+.admin-monitor-geo-note code {
+ display: inline-block;
+ margin: 0 4px;
+ padding: 1px 6px;
+ border-radius: 4px;
+ background: var(--j13-green-bg);
+ font-size: 12px;
+}
+
+@media (max-width: 1100px) {
+ .admin-monitor-today-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+}
+@media (max-width: 960px) {
+ .admin-monitor-main,
+ .admin-monitor-map-body {
+ grid-template-columns: 1fr;
+ }
+ .admin-monitor-rt-value {
+ font-size: 32px;
+ }
+}
+@media (max-width: 640px) {
+ .admin-monitor-today-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.dark .admin-monitor-today-value,
+.dark .admin-monitor-rt-value {
+ color: var(--j13-green);
+}
+.dark .admin-monitor-map-visual {
+ background: color-mix(in srgb, hsl(var(--muted)) 40%, hsl(var(--card)));
+}
+.dark .admin-monitor-geo-table thead th {
+ background: var(--j13-green);
+ color: #fff;
+}
+
diff --git a/frontend/src/types/svg-maps.d.ts b/frontend/src/types/svg-maps.d.ts
new file mode 100644
index 0000000..7c4f082
--- /dev/null
+++ b/frontend/src/types/svg-maps.d.ts
@@ -0,0 +1,17 @@
+declare module '@svg-maps/world' {
+ const map: {
+ label: string;
+ viewBox: string;
+ locations: Array<{ id: string; name: string; path: string }>;
+ };
+ export default map;
+}
+
+declare module '@svg-maps/china' {
+ const map: {
+ label: string;
+ viewBox: string;
+ locations: Array<{ id: string; name: string; path: string }>;
+ };
+ export default map;
+}
diff --git a/frontend/src/utils/asideWidgets.ts b/frontend/src/utils/asideWidgets.ts
index b8f48d8..b31a781 100644
--- a/frontend/src/utils/asideWidgets.ts
+++ b/frontend/src/utils/asideWidgets.ts
@@ -1,19 +1,29 @@
import type { AsideWidget, AsideWidgetId, ForumLimits, ForumLimitsPublic } from '../api/types';
import { DEFAULT_ASIDE_WIDGETS } from '../api/types';
-const ASIDE_WIDGET_IDS: AsideWidgetId[] = ['tag_cloud', 'recent_comments', 'recent_users', 'friend_links'];
+const ASIDE_WIDGET_IDS: AsideWidgetId[] = [
+ 'tag_cloud',
+ 'recent_comments',
+ 'recent_users',
+ 'friend_links',
+ 'showcase',
+];
/** 从 limits 解析右侧栏组件列表(兼容仅有布尔开关的旧数据) */
export function resolveAsideWidgets(
- limits: Pick ,
+ limits: Partial>,
): AsideWidget[] {
if (limits.aside_widgets?.length) {
return normalizeAsideWidgets(limits.aside_widgets);
}
return [
- { id: 'tag_cloud', enabled: limits.aside_show_tag_cloud },
- { id: 'recent_comments', enabled: limits.aside_show_recent_comments },
- { id: 'friend_links', enabled: limits.aside_show_friend_links },
+ { id: 'tag_cloud', enabled: !!limits.aside_show_tag_cloud },
+ { id: 'recent_comments', enabled: !!limits.aside_show_recent_comments },
+ { id: 'friend_links', enabled: limits.aside_show_friend_links !== false },
+ { id: 'showcase', enabled: !!limits.aside_show_showcase },
];
}
@@ -33,12 +43,16 @@ export function normalizeAsideWidgets(widgets: AsideWidget[]): AsideWidget[] {
}
/** 将 aside_widgets 同步回 ForumLimits 布尔字段 */
-export function syncAsideBoolsFromWidgets(widgets: AsideWidget[]): Pick {
+export function syncAsideBoolsFromWidgets(widgets: AsideWidget[]): Pick<
+ ForumLimits,
+ 'aside_show_tag_cloud' | 'aside_show_recent_comments' | 'aside_show_friend_links' | 'aside_show_showcase'
+> {
const normalized = normalizeAsideWidgets(widgets);
return {
aside_show_tag_cloud: normalized.find(w => w.id === 'tag_cloud')?.enabled ?? false,
aside_show_recent_comments: normalized.find(w => w.id === 'recent_comments')?.enabled ?? false,
aside_show_friend_links: normalized.find(w => w.id === 'friend_links')?.enabled ?? true,
+ aside_show_showcase: normalized.find(w => w.id === 'showcase')?.enabled ?? false,
};
}
@@ -64,6 +78,7 @@ export function isAsideWidgetEnabled(widgets: AsideWidget[], id: AsideWidgetId):
aside_show_tag_cloud: false,
aside_show_recent_comments: false,
aside_show_friend_links: false,
+ aside_show_showcase: false,
}).find(w => w.id === id)?.enabled ?? false;
}
diff --git a/go.mod b/go.mod
index 7e155ff..4487436 100644
--- a/go.mod
+++ b/go.mod
@@ -8,9 +8,11 @@ require (
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
+ github.com/ip2location/ip2location-go/v9 v9.8.0
github.com/kardianos/service v1.2.2
github.com/microcosm-cc/bluemonday v1.0.27
github.com/minio/minio-go/v7 v7.0.98
+ github.com/oschwald/maxminddb-golang v1.13.1
golang.org/x/crypto v0.46.0
golang.org/x/image v0.44.0
gopkg.in/ini.v1 v1.67.3
@@ -59,6 +61,7 @@ require (
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+ lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
diff --git a/go.sum b/go.sum
index aa43ea5..e2f99fe 100644
--- a/go.sum
+++ b/go.sum
@@ -48,6 +48,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+github.com/ip2location/ip2location-go/v9 v9.8.0 h1:drPzGjj1EBl45I33ErMHFtIfsQ3mR85dAQbqMDbi9mc=
+github.com/ip2location/ip2location-go/v9 v9.8.0/go.mod h1:MPLnsKxwQlvd2lBNcQCsLoyzJLDBFizuO67wXXdzoyI=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -82,6 +84,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
+github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
@@ -142,6 +146,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
+lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
+lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
diff --git a/handler/api.go b/handler/api.go
index fa82e9f..aee9e2a 100644
--- a/handler/api.go
+++ b/handler/api.go
@@ -158,6 +158,12 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
if recentPosts == nil {
recentPosts = []model.Post{}
}
+ traffic := service.DashboardTraffic{}
+ if h.Monitor != nil {
+ traffic = h.Monitor.DashboardTraffic()
+ } else {
+ traffic.Enabled = h.Settings.MonitorEnabled()
+ }
c.JSON(http.StatusOK, gin.H{
"users": userCount, "posts": postCount, "boards": boardCount,
"comments": commentCount,
@@ -166,6 +172,7 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
"pending_reports": pendingReports,
"pending_friend_links": pendingFriendLinks,
"recent_posts": recentPosts,
+ "traffic": traffic,
})
}
@@ -574,7 +581,7 @@ func (h *Handlers) APIAdminSettings(c *gin.Context) {
"gitea": h.Settings.GiteaSyncConfigPublic(),
"storage": h.Settings.StorageConfigPublic(),
"branding": h.Settings.SiteBranding(),
- "community": h.Settings.CommunityConfig(),
+ "community": h.Settings.CommunityConfigForRequest(communityRequestOrigin(c)),
"filter_words": filterContent,
"filter_word_count": service.CountFilterWords(filterContent),
})
diff --git a/handler/community.go b/handler/community.go
index 3f4a1cb..5a8bd5c 100644
--- a/handler/community.go
+++ b/handler/community.go
@@ -22,7 +22,7 @@ func (h *Handlers) APICommunityHeartbeat(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
- if err := h.Community.ReceiveHeartbeat(req, c.ClientIP()); err != nil {
+ if err := h.Community.ReceiveHeartbeat(req, c.ClientIP(), requestOrigin(c)); err != nil {
if errors.Is(err, service.ErrCommunityHubDisabled) {
c.JSON(http.StatusForbidden, gin.H{"error": "本站未开启社区枢纽"})
return
@@ -43,7 +43,7 @@ func (h *Handlers) APICommunityShowcase(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": []any{}})
return
}
- list, err := h.Community.ListShowcase()
+ list, err := h.Community.ListShowcase(requestOrigin(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"})
return
@@ -57,7 +57,7 @@ func (h *Handlers) APIAdminCommunityInstances(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"instances": []any{}, "hub_enabled": false})
return
}
- cfg := h.Settings.CommunityConfig()
+ cfg := h.Settings.CommunityConfigForRequest(communityRequestOrigin(c))
list, err := h.Community.ListInstances()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"})
@@ -108,23 +108,64 @@ func (h *Handlers) APIAdminUpdateCommunitySettings(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- cfg := h.Settings.CommunityConfig()
+ origin := communityRequestOrigin(c)
+ cfg := h.Settings.CommunityConfigForRequest(origin)
out := gin.H{
"message": "社区设置已保存",
"community": cfg,
}
- if cfg.ReportEnabled && h.Community != nil {
- origin := communityRequestOrigin(c)
+ if cfg.ReportEnabled && !cfg.HubEnabled && h.Community != nil {
if err := h.Community.SendHeartbeatOnce(origin); err != nil {
out["message"] = "社区设置已保存,但心跳未成功"
out["heartbeat_error"] = err.Error()
// 刷新 site_url(可能已由 Origin 持久化)
- out["community"] = h.Settings.CommunityConfig()
+ out["community"] = h.Settings.CommunityConfigForRequest(origin)
}
}
c.JSON(http.StatusOK, out)
}
+// APIAdminUpdateShowcaseEntry 更新开源展柜入口展示位置(左栏 / 右栏 / 页脚)
+func (h *Handlers) APIAdminUpdateShowcaseEntry(c *gin.Context) {
+ var req struct {
+ NavShowShowcase *bool `json:"nav_show_showcase"`
+ FooterShowShowcase *bool `json:"footer_show_showcase"`
+ AsideShowShowcase *bool `json:"aside_show_showcase"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ if req.NavShowShowcase == nil && req.FooterShowShowcase == nil && req.AsideShowShowcase == nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ if req.NavShowShowcase != nil {
+ if err := h.Settings.SetNavShowShowcase(*req.NavShowShowcase); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ }
+ if req.FooterShowShowcase != nil {
+ if err := h.Settings.SetFooterShowShowcase(*req.FooterShowShowcase); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ }
+ if req.AsideShowShowcase != nil {
+ if err := h.Settings.SetAsideShowcaseEnabled(*req.AsideShowShowcase); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "message": "展柜入口已保存",
+ "nav_show_showcase": h.Settings.NavShowShowcase(),
+ "footer_show_showcase": h.Settings.FooterShowShowcase(),
+ "aside_show_showcase": h.Settings.AsideShowShowcase(),
+ })
+}
+
// communityRequestOrigin 优先用浏览器 Origin(Vite 代理时 Host 可能是后端端口)
func communityRequestOrigin(c *gin.Context) string {
if o := strings.TrimSpace(c.GetHeader("Origin")); o != "" {
diff --git a/handler/handlers.go b/handler/handlers.go
index 3df33aa..23ea5ab 100644
--- a/handler/handlers.go
+++ b/handler/handlers.go
@@ -29,6 +29,7 @@ type Handlers struct {
Report *service.ReportService
Backup *service.BackupService
Community *service.CommunityService
+ Monitor *service.MonitorService
Filter *service.SensitiveFilter
Limiter *service.RateLimiter
Settings *service.ForumSettingsService
diff --git a/handler/monitor.go b/handler/monitor.go
new file mode 100644
index 0000000..a0a194e
--- /dev/null
+++ b/handler/monitor.go
@@ -0,0 +1,108 @@
+package handler
+
+import (
+ "net/http"
+ "strconv"
+
+ "git.iioio.com/freefire/jiang13-forum/service"
+ "github.com/gin-gonic/gin"
+)
+
+// APIAdminMonitorOverview 今日概览
+func (h *Handlers) APIAdminMonitorOverview(c *gin.Context) {
+ if h.Monitor == nil {
+ c.JSON(http.StatusOK, service.MonitorOverview{})
+ return
+ }
+ c.JSON(http.StatusOK, h.Monitor.OverviewToday())
+}
+
+// APIAdminMonitorGeo 地理分布
+func (h *Handlers) APIAdminMonitorGeo(c *gin.Context) {
+ if h.Monitor == nil {
+ c.JSON(http.StatusOK, service.MonitorGeoResult{
+ Countries: []service.MonitorGeoItem{},
+ Regions: []service.MonitorRegionItem{},
+ Cities: []service.MonitorCityItem{},
+ ASNs: []service.MonitorASNItem{},
+ })
+ return
+ }
+ c.JSON(http.StatusOK, h.Monitor.GeoStats(c.Query("range")))
+}
+
+// APIAdminMonitorStats 维度排行
+func (h *Handlers) APIAdminMonitorStats(c *gin.Context) {
+ if h.Monitor == nil {
+ c.JSON(http.StatusOK, gin.H{"items": []service.MonitorStatItem{}})
+ return
+ }
+ items := h.Monitor.DimStats(c.Query("dim"), c.Query("range"))
+ c.JSON(http.StatusOK, gin.H{"dim": c.Query("dim"), "range": c.Query("range"), "items": items})
+}
+
+// APIAdminMonitorLogs 请求日志
+func (h *Handlers) APIAdminMonitorLogs(c *gin.Context) {
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
+ if h.Monitor == nil {
+ c.JSON(http.StatusOK, gin.H{"items": []service.MonitorLogItem{}, "total": 0, "page": page, "size": size})
+ return
+ }
+ items, total := h.Monitor.ListLogs(page, size, c.Query("method"), c.Query("path"), c.Query("status"), c.Query("ip"))
+ c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "size": size})
+}
+
+// APIAdminMonitorRealtime 实时
+func (h *Handlers) APIAdminMonitorRealtime(c *gin.Context) {
+ if h.Monitor == nil {
+ c.JSON(http.StatusOK, service.MonitorRealtime{HourlySeries: []service.MonitorRealtimePoint{}})
+ return
+ }
+ c.JSON(http.StatusOK, h.Monitor.Realtime())
+}
+
+// APIAdminGetMonitorSettings 读取监控设置
+func (h *Handlers) APIAdminGetMonitorSettings(c *gin.Context) {
+ cfg := h.Settings.MonitorConfig()
+ if h.Monitor != nil {
+ h.Monitor.EnrichGeoMeta(&cfg)
+ }
+ c.JSON(http.StatusOK, cfg)
+}
+
+// APIAdminUpdateMonitorSettings 更新监控设置
+func (h *Handlers) APIAdminUpdateMonitorSettings(c *gin.Context) {
+ var req service.MonitorConfig
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ if err := h.Settings.UpdateMonitorConfig(req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ cfg := h.Settings.MonitorConfig()
+ if h.Monitor != nil {
+ h.Monitor.EnrichGeoMeta(&cfg)
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "监控设置已保存", "monitor": cfg})
+}
+
+// APIMonitorPageview 前台 SPA 路由 pageview 信标(公开,受 monitor_enabled 控制)
+func (h *Handlers) APIMonitorPageview(c *gin.Context) {
+ if h.Monitor == nil || !h.Settings.MonitorEnabled() {
+ c.Status(http.StatusNoContent)
+ return
+ }
+ var req service.PageViewInput
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ if err := h.Monitor.RecordPageView(c.Request, c.Request.RemoteAddr, req); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "记录失败"})
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
diff --git a/middleware/accesslog.go b/middleware/accesslog.go
new file mode 100644
index 0000000..f556b8a
--- /dev/null
+++ b/middleware/accesslog.go
@@ -0,0 +1,33 @@
+package middleware
+
+import (
+ "time"
+
+ "git.iioio.com/freefire/jiang13-forum/service"
+ "github.com/gin-gonic/gin"
+)
+
+// AccessLogMiddleware 服务端访问日志采集(受 monitor_enabled 控制;热路径仅入队,后台写 jsonl)
+func AccessLogMiddleware(mon *service.MonitorService) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if mon == nil || !mon.Enabled() {
+ c.Next()
+ return
+ }
+ path := c.Request.URL.Path
+ if mon.ShouldSkip(path) {
+ c.Next()
+ return
+ }
+ start := time.Now()
+ c.Next()
+ status := c.Writer.Status()
+ bytes := int64(c.Writer.Size())
+ if bytes < 0 {
+ bytes = 0
+ }
+ dur := int(time.Since(start).Milliseconds())
+ row := mon.BuildAccessLog(c.Request, c.Request.RemoteAddr, status, bytes, dur)
+ mon.Enqueue(row)
+ }
+}
diff --git a/model/db.go b/model/db.go
index fea011c..40b1381 100644
--- a/model/db.go
+++ b/model/db.go
@@ -13,25 +13,36 @@ import (
var DB *gorm.DB
-// InitDB 初始化 SQLite 并自动迁移
-func InitDB(dbPath string) error {
+// MonitorDB 网站监控独立库(page_views),与主库 jiang13.db 分离以免撑大备份
+var MonitorDB *gorm.DB
+
+func openSQLite(dbPath string) (*gorm.DB, error) {
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
- return fmt.Errorf("创建数据库目录失败: %w", err)
+ return nil, fmt.Errorf("创建数据库目录失败: %w", err)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
- return fmt.Errorf("连接 SQLite 失败: %w", err)
+ return nil, fmt.Errorf("连接 SQLite 失败: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
- return err
+ return nil, err
}
sqlDB.SetMaxOpenConns(1)
+ return db, nil
+}
+
+// InitDB 初始化主库 SQLite 并自动迁移(不含 page_views)
+func InitDB(dbPath string) error {
+ db, err := openSQLite(dbPath)
+ if err != nil {
+ return err
+ }
if err := db.AutoMigrate(
&User{}, &Board{}, &Post{}, &Comment{},
@@ -60,6 +71,20 @@ func InitDB(dbPath string) error {
return nil
}
+// InitMonitorDB 打开独立监控库(page_views 只写此处)
+func InitMonitorDB(monitorDBPath string) error {
+ db, err := openSQLite(monitorDBPath)
+ if err != nil {
+ return fmt.Errorf("监控库初始化失败: %w", err)
+ }
+ if err := db.AutoMigrate(&PageView{}); err != nil {
+ return fmt.Errorf("监控库迁移失败: %w", err)
+ }
+ MonitorDB = db
+ log.Println("[model] 监控库初始化完成:", monitorDBPath)
+ return nil
+}
+
// PingDB 检测数据库连接是否可用(供健康检查使用)
func PingDB() error {
if DB == nil {
diff --git a/model/models.go b/model/models.go
index f41543b..3229731 100644
--- a/model/models.go
+++ b/model/models.go
@@ -481,3 +481,20 @@ type CommunityInstance struct {
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `gorm:"index" json:"last_seen_at"`
}
+
+// PageView 前台路由浏览量(第一方 SPA 信标;存独立 monitor.db;请求日志走 jsonl)
+type PageView struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ CreatedAt time.Time `gorm:"index;not null" json:"created_at"`
+ Path string `gorm:"size:512;index" json:"path"`
+ Referrer string `gorm:"size:512" json:"referrer"`
+ IP string `gorm:"size:64;index" json:"ip"` // 完整客户端 IP
+ UA string `gorm:"size:512" json:"ua"`
+ Country string `gorm:"size:8;index" json:"country"`
+ Region string `gorm:"size:64" json:"region"`
+ RegionISO string `gorm:"size:16;index" json:"region_iso"`
+ City string `gorm:"size:64;index" json:"city"`
+ ASN uint `gorm:"index" json:"asn"`
+ ASOrg string `gorm:"size:128" json:"as_org"`
+ IsBot bool `gorm:"default:false;index" json:"is_bot"`
+}
diff --git a/model/monitor_db_test.go b/model/monitor_db_test.go
new file mode 100644
index 0000000..38bfc63
--- /dev/null
+++ b/model/monitor_db_test.go
@@ -0,0 +1,54 @@
+package model
+
+import (
+ "path/filepath"
+ "testing"
+
+ "gorm.io/gorm"
+)
+
+func TestInitMonitorDBUsesSeparateFile(t *testing.T) {
+ dir := t.TempDir()
+ mainPath := filepath.Join(dir, "jiang13.db")
+ monPath := filepath.Join(dir, "monitor.db")
+
+ prevDB, prevMon := DB, MonitorDB
+ t.Cleanup(func() {
+ closeGorm(MonitorDB)
+ closeGorm(DB)
+ DB, MonitorDB = prevDB, prevMon
+ })
+
+ if err := InitDB(mainPath); err != nil {
+ t.Fatal(err)
+ }
+ if err := InitMonitorDB(monPath); err != nil {
+ t.Fatal(err)
+ }
+ if tableExists(DB, "page_views") {
+ t.Fatal("主库不应创建 page_views")
+ }
+ if !tableExists(MonitorDB, "page_views") {
+ t.Fatal("监控库缺少 page_views")
+ }
+}
+
+func tableExists(db *gorm.DB, name string) bool {
+ if db == nil {
+ return false
+ }
+ var n int
+ _ = db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&n).Error
+ return n > 0
+}
+
+func closeGorm(db *gorm.DB) {
+ if db == nil {
+ return
+ }
+ sqlDB, err := db.DB()
+ if err != nil {
+ return
+ }
+ _ = sqlDB.Close()
+}
diff --git a/router/router.go b/router/router.go
index f2d05f1..a5be21d 100644
--- a/router/router.go
+++ b/router/router.go
@@ -21,6 +21,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
r.Use(gin.Recovery())
r.Use(gin.Logger())
+ filter := service.NewSensitiveFilter()
+ _ = service.WriteDefaultFilterWords(cfg.FilterWordsPath())
+ filter.LoadFromFile(cfg.FilterWordsPath())
+
+ settingsSvc := service.NewForumSettingsService()
+ settingsSvc.SetCommunityHubEnabled(cfg.CommunityHub)
+ monitorSvc := service.NewMonitorService(settingsSvc, cfg.DataDir, cfg.JWTSecret)
+ monitorSvc.StartBackground()
+ r.Use(middleware.AccessLogMiddleware(monitorSvc))
+
// dev 模式:跳过内嵌静态资源,前端由 Vite 开发服务器(:5173)提供
// 用户应访问 5173 端口,Vite 通过 proxy 将 /api 等请求转发到本服务(:3000)
if !cfg.DevMode {
@@ -31,12 +41,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
fmt.Fprintf(os.Stderr, "[dev] 后端仅提供 API,前端请访问 http://localhost:5173\n")
}
- filter := service.NewSensitiveFilter()
- _ = service.WriteDefaultFilterWords(cfg.FilterWordsPath())
- filter.LoadFromFile(cfg.FilterWordsPath())
-
- settingsSvc := service.NewForumSettingsService()
- settingsSvc.SetCommunityHubEnabled(cfg.CommunityHub)
communitySvc := service.NewCommunityService(settingsSvc)
communitySvc.StartBackground()
if cfg.CommunityHub {
@@ -93,7 +97,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
h := &handler.Handlers{
Cfg: cfg, Store: uploadStore, Auth: authSvc, User: userSvc, Board: boardSvc,
Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc,
- Backup: backupSvc, Community: communitySvc,
+ Backup: backupSvc, Community: communitySvc, Monitor: monitorSvc,
Filter: filter, Limiter: limiter, Settings: settingsSvc,
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
OIDC: oidcSvc, Gitea: giteaSvc,
@@ -132,6 +136,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/stats", h.APIStats)
pubAPI.POST("/community/heartbeat", middleware.RateLimitMiddleware(limiter, "community_heartbeat"), h.APICommunityHeartbeat)
pubAPI.GET("/community/showcase", h.APICommunityShowcase)
+ pubAPI.POST("/monitor/pageview", middleware.RateLimitMiddleware(limiter, "monitor_pageview"), h.APIMonitorPageview)
pubAPI.GET("/forum-limits", h.APIForumLimits)
pubAPI.GET("/site-branding", h.APISiteBranding)
pubAPI.GET("/pages", h.APIPages)
@@ -214,8 +219,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAPI.GET("/settings", h.APIAdminSettings)
adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings)
adminAPI.PUT("/settings/community", h.APIAdminUpdateCommunitySettings)
+ adminAPI.GET("/settings/monitor", h.APIAdminGetMonitorSettings)
+ adminAPI.PUT("/settings/monitor", h.APIAdminUpdateMonitorSettings)
+ adminAPI.GET("/monitor/overview", h.APIAdminMonitorOverview)
+ adminAPI.GET("/monitor/geo", h.APIAdminMonitorGeo)
+ adminAPI.GET("/monitor/stats", h.APIAdminMonitorStats)
+ adminAPI.GET("/monitor/logs", h.APIAdminMonitorLogs)
+ adminAPI.GET("/monitor/realtime", h.APIAdminMonitorRealtime)
adminAPI.GET("/community/instances", h.APIAdminCommunityInstances)
adminAPI.PUT("/community/instances/:id/feature", h.APIAdminFeatureCommunityInstance)
+ adminAPI.PUT("/community/showcase-entry", h.APIAdminUpdateShowcaseEntry)
adminAPI.PUT("/settings/mail", h.APIAdminUpdateMailSettings)
adminAPI.POST("/settings/mail/test", h.APIAdminTestMail)
adminAPI.PUT("/settings/oidc", h.APIAdminUpdateOIDCSettings)
@@ -293,7 +306,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin())
{
adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
- for _, page := range []string{"dashboard", "boards", "pages", "links", "posts", "comments", "reports", "users", "badges", "media", "settings"} {
+ for _, page := range []string{
+ "dashboard", "boards", "pages", "links", "community", "posts",
+ "comments", "reports", "users", "badges", "media", "monitor", "settings",
+ } {
adminAuth.GET("/"+page, embed_static.ServeSPANoIndex)
}
}
diff --git a/service/aside_widgets_test.go b/service/aside_widgets_test.go
index 996cba5..ee32068 100644
--- a/service/aside_widgets_test.go
+++ b/service/aside_widgets_test.go
@@ -9,16 +9,16 @@ func TestNormalizeAsideWidgetsPreservesOrder(t *testing.T) {
{ID: AsideWidgetRecentComments, Enabled: false},
}
out := NormalizeAsideWidgets(in)
- if len(out) != 4 {
- t.Fatalf("want 4 widgets, got %d", len(out))
+ if len(out) != 5 {
+ t.Fatalf("want 5 widgets, got %d", len(out))
}
- want := []string{AsideWidgetFriendLinks, AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers}
+ want := []string{AsideWidgetFriendLinks, AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetShowcase}
for i, id := range want {
if out[i].ID != id {
t.Fatalf("index %d: want %s, got %s", i, id, out[i].ID)
}
}
- if !out[0].Enabled || !out[1].Enabled || out[2].Enabled || out[3].Enabled {
+ if !out[0].Enabled || !out[1].Enabled || out[2].Enabled || out[3].Enabled || out[4].Enabled {
t.Fatalf("enabled flags mismatch: %+v", out)
}
}
diff --git a/service/backup.go b/service/backup.go
index 527df60..63abe4f 100644
--- a/service/backup.go
+++ b/service/backup.go
@@ -17,7 +17,7 @@ func NewBackupService(dbPath, dataDir string) *BackupService {
return &BackupService{dbPath: dbPath, dataDir: dataDir}
}
-// ExportSQLite 导出 SQLite 备份文件到 data 目录
+// ExportSQLite 导出主库 SQLite 备份到 data 目录(不含 monitor.db)
func (s *BackupService) ExportSQLite() (string, error) {
src, err := os.Open(s.dbPath)
if err != nil {
diff --git a/service/community.go b/service/community.go
index 000126c..bf746f0 100644
--- a/service/community.go
+++ b/service/community.go
@@ -162,6 +162,10 @@ func (c *CommunityService) trySendHeartbeat() {
// SendHeartbeatOnce 立即发送一次心跳;requestOrigin 可在管理端保存时传入以补全本站地址
func (c *CommunityService) SendHeartbeatOnce(requestOrigin string) error {
+ // 枢纽站(含官网)不出站上报,避免自己心跳给自己
+ if c.settings.CommunityHubEnabled(requestOrigin) {
+ return nil
+ }
cfg := c.settings.CommunityConfig()
if !cfg.ReportEnabled {
return nil
@@ -228,9 +232,9 @@ func (c *CommunityService) buildPayload(requestOrigin string) (*CommunityHeartbe
}, nil
}
-// ReceiveHeartbeat 枢纽接收心跳并 upsert
-func (c *CommunityService) ReceiveHeartbeat(in CommunityHeartbeatPayload, remoteIP string) error {
- if !c.settings.CommunityConfig().HubEnabled {
+// ReceiveHeartbeat 枢纽接收心跳并 upsert;requestHint 用于按请求 Host 识别官网
+func (c *CommunityService) ReceiveHeartbeat(in CommunityHeartbeatPayload, remoteIP, requestHint string) error {
+ if !c.settings.CommunityHubEnabled(requestHint) {
return ErrCommunityHubDisabled
}
in.InstanceID = strings.TrimSpace(in.InstanceID)
@@ -343,9 +347,9 @@ func (c *CommunityService) SetInstanceFeatured(instanceID string, in CommunityFe
}, nil
}
-// ListShowcase 公开展柜:仅精选;枢纽关闭时返回空
-func (c *CommunityService) ListShowcase() ([]CommunityShowcaseItem, error) {
- if !c.settings.CommunityConfig().HubEnabled {
+// ListShowcase 公开展柜:仅精选;枢纽关闭时返回空;requestHint 用于按 Host 识别官网
+func (c *CommunityService) ListShowcase(requestHint string) ([]CommunityShowcaseItem, error) {
+ if !c.settings.CommunityHubEnabled(requestHint) {
return []CommunityShowcaseItem{}, nil
}
var rows []model.CommunityInstance
diff --git a/service/community_test.go b/service/community_test.go
index 0923016..edba2bd 100644
--- a/service/community_test.go
+++ b/service/community_test.go
@@ -47,7 +47,7 @@ func TestCommunityHeartbeatHubDisabled(t *testing.T) {
Version: "1.0.0",
Users: 1,
Posts: 2,
- }, "127.0.0.1")
+ }, "127.0.0.1", "https://other.example")
if !errors.Is(err, ErrCommunityHubDisabled) {
t.Fatalf("want ErrCommunityHubDisabled, got %v", err)
}
@@ -65,12 +65,12 @@ func TestCommunityHeartbeatAcceptAndList(t *testing.T) {
Users: 10,
Posts: 20,
}
- if err := svc.ReceiveHeartbeat(payload, "203.0.113.9"); err != nil {
+ if err := svc.ReceiveHeartbeat(payload, "203.0.113.9", ""); err != nil {
t.Fatal(err)
}
payload.Users = 11
payload.Posts = 21
- if err := svc.ReceiveHeartbeat(payload, "203.0.113.9"); err != nil {
+ if err := svc.ReceiveHeartbeat(payload, "203.0.113.9", ""); err != nil {
t.Fatal(err)
}
@@ -129,7 +129,7 @@ func TestCommunityHeartbeatBadURL(t *testing.T) {
InstanceID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
SiteURL: "javascript:alert(1)",
SiteName: "坏",
- }, "127.0.0.1")
+ }, "127.0.0.1", "")
if !errors.Is(err, ErrCommunityBadPayload) {
t.Fatalf("want ErrCommunityBadPayload, got %v", err)
}
@@ -204,11 +204,11 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
SiteName: "示例论坛",
Version: "2.0.0",
}
- if err := svc.ReceiveHeartbeat(payload, "127.0.0.1"); err != nil {
+ if err := svc.ReceiveHeartbeat(payload, "127.0.0.1", ""); err != nil {
t.Fatal(err)
}
- empty, err := svc.ListShowcase()
+ empty, err := svc.ListShowcase("")
if err != nil {
t.Fatal(err)
}
@@ -227,7 +227,7 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
t.Fatalf("unexpected view: %+v", view)
}
- items, err := svc.ListShowcase()
+ items, err := svc.ListShowcase("")
if err != nil {
t.Fatal(err)
}
@@ -237,10 +237,10 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
// 心跳更新不得清掉精选
payload.Users = 9
- if err := svc.ReceiveHeartbeat(payload, "127.0.0.1"); err != nil {
+ if err := svc.ReceiveHeartbeat(payload, "127.0.0.1", ""); err != nil {
t.Fatal(err)
}
- items, err = svc.ListShowcase()
+ items, err = svc.ListShowcase("")
if err != nil {
t.Fatal(err)
}
@@ -249,7 +249,7 @@ func TestCommunityFeatureAndShowcase(t *testing.T) {
}
settings.SetCommunityHubEnabled(false)
- items, err = svc.ListShowcase()
+ items, err = svc.ListShowcase("")
if err != nil {
t.Fatal(err)
}
@@ -281,3 +281,154 @@ func TestCommunitySiteURLFromOrigin(t *testing.T) {
t.Fatalf("payload site_url=%s", payload.SiteURL)
}
}
+
+func TestCommunityHubByOfficialHost(t *testing.T) {
+ settings, svc := setupCommunityTest(t)
+
+ // 其它域名、未开运维开关 → 拒绝
+ err := svc.ReceiveHeartbeat(CommunityHeartbeatPayload{
+ InstanceID: "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
+ SiteURL: "https://forum.example.org",
+ SiteName: "他站",
+ Version: "1.0.0",
+ }, "127.0.0.1", "https://other.example")
+ if !errors.Is(err, ErrCommunityHubDisabled) {
+ t.Fatalf("want disabled for other host, got %v", err)
+ }
+
+ // 请求 Host 为官网 → 自动枢纽
+ payload := CommunityHeartbeatPayload{
+ InstanceID: "cccccccc-dddd-eeee-ffff-000000000001",
+ SiteURL: "https://forum.example.org",
+ SiteName: "上报站",
+ Version: "1.1.0",
+ Users: 3,
+ Posts: 5,
+ }
+ if err := svc.ReceiveHeartbeat(payload, "203.0.113.1", "https://bbs.iioio.com"); err != nil {
+ t.Fatal(err)
+ }
+ if !settings.CommunityHubEnabled("https://www.bbs.iioio.com") {
+ t.Fatal("www 前缀也应识别为官网")
+ }
+ if !settings.CommunityConfigForRequest("https://bbs.iioio.com").HubEnabled {
+ t.Fatal("CommunityConfigForRequest should enable hub for official host")
+ }
+
+ list, err := svc.ListInstances()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(list) != 1 {
+ t.Fatalf("want 1 instance, got %d", len(list))
+ }
+
+ // 已存 ROOT_URL 为官网时,无请求上下文也应开启
+ if err := settings.setString(SettingOIDCRootURL, DefaultCommunityHubURL); err != nil {
+ t.Fatal(err)
+ }
+ if !settings.CommunityHubEnabled("") {
+ t.Fatal("ROOT_URL as official should enable hub without request hint")
+ }
+}
+
+func TestCommunityHubSkipsOutboundHeartbeat(t *testing.T) {
+ settings, svc := setupCommunityTest(t)
+ var hits atomic.Int32
+
+ hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"ok":true}`))
+ }))
+ t.Cleanup(hub.Close)
+
+ prevHub := communityHubBaseURL
+ communityHubBaseURL = hub.URL
+ t.Cleanup(func() { communityHubBaseURL = prevHub })
+
+ if err := settings.setString(SettingOIDCRootURL, DefaultCommunityHubURL); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := settings.UpdateCommunityConfig(CommunityConfig{ReportEnabled: true}); err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.SendHeartbeatOnce(""); err != nil {
+ t.Fatal(err)
+ }
+ if hits.Load() != 0 {
+ t.Fatal("hub site must not send outbound heartbeat")
+ }
+
+ // 运维开关开启同样跳过
+ settings2, svc2 := setupCommunityTest(t)
+ settings2.SetCommunityHubEnabled(true)
+ if err := settings2.setString(SettingOIDCRootURL, "http://mirror.local"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := settings2.UpdateCommunityConfig(CommunityConfig{ReportEnabled: true}); err != nil {
+ t.Fatal(err)
+ }
+ prevHub2 := communityHubBaseURL
+ communityHubBaseURL = hub.URL
+ t.Cleanup(func() { communityHubBaseURL = prevHub2 })
+ if err := svc2.SendHeartbeatOnce(""); err != nil {
+ t.Fatal(err)
+ }
+ if hits.Load() != 0 {
+ t.Fatal("ops hub must not send outbound heartbeat")
+ }
+}
+
+func TestHostFromURLOrHost(t *testing.T) {
+ cases := []struct {
+ in, want string
+ }{
+ {"https://bbs.iioio.com", "bbs.iioio.com"},
+ {"https://www.bbs.iioio.com/", "bbs.iioio.com"},
+ {"https://bbs.iioio.com:443/path", "bbs.iioio.com"},
+ {"BBS.IIOIO.COM", "bbs.iioio.com"},
+ {"https://other.example", "other.example"},
+ {"", ""},
+ }
+ for _, tc := range cases {
+ if got := hostFromURLOrHost(tc.in); got != tc.want {
+ t.Fatalf("hostFromURLOrHost(%q)=%q want %q", tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestAsideShowcaseEntrySync(t *testing.T) {
+ settings, _ := setupCommunityTest(t)
+ if settings.NavShowShowcase() || settings.FooterShowShowcase() || settings.AsideShowShowcase() {
+ t.Fatal("showcase entry should be off by default")
+ }
+ if err := settings.SetNavShowShowcase(true); err != nil {
+ t.Fatal(err)
+ }
+ if err := settings.SetFooterShowShowcase(true); err != nil {
+ t.Fatal(err)
+ }
+ if err := settings.SetAsideShowcaseEnabled(true); err != nil {
+ t.Fatal(err)
+ }
+ if !settings.NavShowShowcase() || !settings.FooterShowShowcase() || !settings.AsideShowShowcase() {
+ t.Fatal("showcase entry should be enabled")
+ }
+ cfg := settings.Limits()
+ if !cfg.NavShowShowcase || !cfg.FooterShowShowcase || !cfg.AsideShowShowcase {
+ t.Fatalf("limits missing showcase flags: %+v", cfg)
+ }
+ found := false
+ for _, w := range cfg.AsideWidgets {
+ if w.ID == AsideWidgetShowcase {
+ found = true
+ if !w.Enabled {
+ t.Fatal("aside showcase widget should be enabled")
+ }
+ }
+ }
+ if !found {
+ t.Fatal("aside widgets should include showcase")
+ }
+}
diff --git a/service/geo_zh.go b/service/geo_zh.go
new file mode 100644
index 0000000..e74ba65
--- /dev/null
+++ b/service/geo_zh.go
@@ -0,0 +1,258 @@
+package service
+
+import (
+ "strconv"
+ "strings"
+)
+
+// countryZh ISO2 → 中文国名(展示用;存储仍用 ISO)
+var countryZh = map[string]string{
+ "CN": "中国", "HK": "中国香港", "MO": "中国澳门", "TW": "中国台湾",
+ "US": "美国", "JP": "日本", "KR": "韩国", "SG": "新加坡",
+ "GB": "英国", "DE": "德国", "FR": "法国", "RU": "俄罗斯",
+ "CA": "加拿大", "AU": "澳大利亚", "IN": "印度", "TH": "泰国",
+ "VN": "越南", "MY": "马来西亚", "ID": "印度尼西亚", "PH": "菲律宾",
+ "NL": "荷兰", "IT": "意大利", "ES": "西班牙", "BR": "巴西",
+ "MX": "墨西哥", "SE": "瑞典", "CH": "瑞士", "PL": "波兰",
+ "TR": "土耳其", "SA": "沙特阿拉伯", "AE": "阿联酋", "NZ": "新西兰",
+ "IE": "爱尔兰", "BE": "比利时", "AT": "奥地利", "NO": "挪威",
+ "DK": "丹麦", "FI": "芬兰", "PT": "葡萄牙", "CZ": "捷克",
+ "UA": "乌克兰", "IL": "以色列", "ZA": "南非", "AR": "阿根廷",
+}
+
+type cnRegionMeta struct {
+ Name string
+ ISO string
+}
+
+// cnRegionMap 中国省级英文名/别名 → 中文名 + 地图 ISO
+var cnRegionMap = map[string]cnRegionMeta{
+ "beijing": {Name: "北京", ISO: "BJ"},
+ "peking": {Name: "北京", ISO: "BJ"},
+ "tianjin": {Name: "天津", ISO: "TJ"},
+ "shanghai": {Name: "上海", ISO: "SH"},
+ "chongqing": {Name: "重庆", ISO: "CQ"},
+ "chungking": {Name: "重庆", ISO: "CQ"},
+ "hebei": {Name: "河北", ISO: "HE"},
+ "shanxi": {Name: "山西", ISO: "SX"},
+ "liaoning": {Name: "辽宁", ISO: "LN"},
+ "jilin": {Name: "吉林", ISO: "JL"},
+ "heilongjiang": {Name: "黑龙江", ISO: "HL"},
+ "jiangsu": {Name: "江苏", ISO: "JS"},
+ "zhejiang": {Name: "浙江", ISO: "ZJ"},
+ "anhui": {Name: "安徽", ISO: "AH"},
+ "fujian": {Name: "福建", ISO: "FJ"},
+ "jiangxi": {Name: "江西", ISO: "JX"},
+ "shandong": {Name: "山东", ISO: "SD"},
+ "henan": {Name: "河南", ISO: "HA"},
+ "hubei": {Name: "湖北", ISO: "HB"},
+ "hunan": {Name: "湖南", ISO: "HN"},
+ "guangdong": {Name: "广东", ISO: "GD"},
+ "guangxi": {Name: "广西", ISO: "GX"},
+ "hainan": {Name: "海南", ISO: "HI"},
+ "sichuan": {Name: "四川", ISO: "SC"},
+ "guizhou": {Name: "贵州", ISO: "GZ"},
+ "yunnan": {Name: "云南", ISO: "YN"},
+ "xizang": {Name: "西藏", ISO: "XZ"},
+ "tibet": {Name: "西藏", ISO: "XZ"},
+ "shaanxi": {Name: "陕西", ISO: "SN"},
+ "shaanxi province": {Name: "陕西", ISO: "SN"},
+ "gansu": {Name: "甘肃", ISO: "GS"},
+ "qinghai": {Name: "青海", ISO: "QH"},
+ "ningxia": {Name: "宁夏", ISO: "NX"},
+ "xinjiang": {Name: "新疆", ISO: "XJ"},
+ "inner mongolia": {Name: "内蒙古", ISO: "NM"},
+ "nei mongol": {Name: "内蒙古", ISO: "NM"},
+ "hong kong": {Name: "香港", ISO: "HK"},
+ "macau": {Name: "澳门", ISO: "MO"},
+ "macao": {Name: "澳门", ISO: "MO"},
+ "taiwan": {Name: "台湾", ISO: "TW"},
+}
+
+// cityZh 常见城市英文名 → 中文
+var cityZh = map[string]string{
+ "beijing": "北京", "peking": "北京",
+ "shanghai": "上海",
+ "chongqing": "重庆", "chungking": "重庆",
+ "tianjin": "天津",
+ "hangzhou": "杭州",
+ "shenzhen": "深圳",
+ "guangzhou": "广州", "canton": "广州",
+ "chengdu": "成都",
+ "wuhan": "武汉",
+ "xi'an": "西安", "xian": "西安",
+ "nanjing": "南京", "nanking": "南京",
+ "suzhou": "苏州",
+ "qingdao": "青岛",
+ "dalian": "大连",
+ "ningbo": "宁波",
+ "xiamen": "厦门", "amoy": "厦门",
+ "changsha": "长沙",
+ "zhengzhou": "郑州",
+ "jinan": "济南",
+ "harbin": "哈尔滨",
+ "shenyang": "沈阳",
+ "kunming": "昆明",
+ "nanning": "南宁",
+ "urumqi": "乌鲁木齐",
+ "lhasa": "拉萨",
+ "hohhot": "呼和浩特",
+ "taipei": "台北",
+ "hong kong": "香港",
+}
+
+// asnOrgZh 常见 ASN / AS 组织名 → 中文运营商
+var asnOrgZh = map[uint]string{
+ 4134: "中国电信",
+ 4812: "中国电信",
+ 4837: "中国联通",
+ 4808: "中国联通",
+ 9808: "中国移动",
+ 56040: "中国移动",
+ 56041: "中国移动",
+ 56042: "中国移动",
+ 4538: "教育网",
+ 23910: "教育网",
+ 58539: "教育网",
+ 7497: "教育网",
+}
+
+var asnOrgKeywordZh = []struct {
+ kw string
+ zh string
+}{
+ {"chinanet", "中国电信"},
+ {"china telecom", "中国电信"},
+ {"chinatelecom", "中国电信"},
+ {"unicom", "中国联通"},
+ {"china unicom", "中国联通"},
+ {"mobile", "中国移动"},
+ {"china mobile", "中国移动"},
+ {"cernet", "教育网"},
+ {"education", "教育网"},
+}
+
+// CountryLabelZh ISO2 → 中文国名(未知则返回原码)
+func CountryLabelZh(iso string) string {
+ iso = strings.ToUpper(strings.TrimSpace(iso))
+ if iso == "" {
+ return ""
+ }
+ if v, ok := countryZh[iso]; ok {
+ return v
+ }
+ return iso
+}
+
+// ApplyGeoZh 就地补充中文省/市/运营商;country 保持 ISO2
+func ApplyGeoZh(g *GeoInfo) {
+ if g == nil {
+ return
+ }
+ g.Country = normalizeCountryCode(g.Country)
+ if g.Country == "CN" || g.Country == "HK" || g.Country == "MO" || g.Country == "TW" {
+ mapCNRegionCity(g)
+ }
+ if g.ASN > 0 {
+ if zh, ok := asnOrgZh[g.ASN]; ok {
+ g.ASOrg = zh
+ } else if g.ASOrg != "" {
+ g.ASOrg = mapASOrgZh(g.ASOrg)
+ }
+ } else if g.ASOrg != "" {
+ g.ASOrg = mapASOrgZh(g.ASOrg)
+ }
+ g.Region = truncateRunes(strings.TrimSpace(g.Region), 64)
+ g.City = truncateRunes(strings.TrimSpace(g.City), 64)
+ g.ASOrg = truncateRunes(strings.TrimSpace(g.ASOrg), 128)
+}
+
+func mapCNRegionCity(g *GeoInfo) {
+ if g.RegionISO == "" {
+ if meta, ok := lookupCNRegion(g.Region); ok {
+ g.Region = meta.Name
+ g.RegionISO = meta.ISO
+ }
+ } else if g.Region != "" {
+ if meta, ok := lookupCNRegion(g.Region); ok {
+ if g.RegionISO == "" {
+ g.RegionISO = meta.ISO
+ }
+ g.Region = meta.Name
+ }
+ } else if g.RegionISO != "" {
+ for _, meta := range cnRegionMap {
+ if meta.ISO == strings.ToUpper(g.RegionISO) {
+ g.Region = meta.Name
+ break
+ }
+ }
+ }
+ if g.City != "" {
+ if zh, ok := lookupCityZh(g.City); ok {
+ g.City = zh
+ }
+ }
+ // 直辖市:BIN 有时只给城市不给省
+ if g.Region == "" && g.City != "" {
+ switch g.City {
+ case "北京", "上海", "天津", "重庆":
+ g.Region = g.City
+ if meta, ok := cnRegionMap[strings.ToLower(g.City)]; ok {
+ g.RegionISO = meta.ISO
+ }
+ }
+ }
+}
+
+func lookupCNRegion(name string) (cnRegionMeta, bool) {
+ key := strings.ToLower(strings.TrimSpace(name))
+ if key == "" {
+ return cnRegionMeta{}, false
+ }
+ if meta, ok := cnRegionMap[key]; ok {
+ return meta, true
+ }
+ // 去掉常见后缀再试
+ for _, suffix := range []string{" province", " sheng", " autonomous region", " municipality"} {
+ if strings.HasSuffix(key, suffix) {
+ if meta, ok := cnRegionMap[strings.TrimSuffix(key, suffix)]; ok {
+ return meta, true
+ }
+ }
+ }
+ return cnRegionMeta{}, false
+}
+
+func lookupCityZh(name string) (string, bool) {
+ key := strings.ToLower(strings.TrimSpace(name))
+ if zh, ok := cityZh[key]; ok {
+ return zh, true
+ }
+ return "", false
+}
+
+func mapASOrgZh(org string) string {
+ l := strings.ToLower(strings.TrimSpace(org))
+ if l == "" {
+ return org
+ }
+ for _, rule := range asnOrgKeywordZh {
+ if strings.Contains(l, rule.kw) {
+ return rule.zh
+ }
+ }
+ // "AS4134 Chinanet" 等形式
+ if strings.HasPrefix(l, "as") {
+ num := strings.TrimPrefix(l, "as")
+ if i := strings.IndexByte(num, ' '); i >= 0 {
+ num = num[:i]
+ }
+ if n, err := strconv.ParseUint(num, 10, 32); err == nil {
+ if zh, ok := asnOrgZh[uint(n)]; ok {
+ return zh
+ }
+ }
+ }
+ return org
+}
diff --git a/service/geoip.go b/service/geoip.go
new file mode 100644
index 0000000..3145c22
--- /dev/null
+++ b/service/geoip.go
@@ -0,0 +1,333 @@
+package service
+
+import (
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/ip2location/ip2location-go/v9"
+ "github.com/oschwald/maxminddb-golang"
+)
+
+// GeoInfo IP 地理与运营商解析结果
+type GeoInfo struct {
+ Country string
+ Region string // 省/州(写入前经 ApplyGeoZh 中文化)
+ RegionISO string // 省/州 ISO,如 GD
+ City string
+ ASN uint
+ ASOrg string
+}
+
+type geoIPSuite struct {
+ dataDir string
+ mu sync.RWMutex
+ binV4 *binHandle
+ binV6 *binHandle
+ asn *mmdbHandle
+ country *mmdbHandle // 可选:BIN 未命中时国家兜底
+}
+
+type binHandle struct {
+ path string
+ db *ip2location.DB
+ mod time.Time
+}
+
+type mmdbHandle struct {
+ path string
+ db *maxminddb.Reader
+ mod time.Time
+}
+
+func newGeoIPSuite(dataDir string) *geoIPSuite {
+ s := &geoIPSuite{dataDir: dataDir}
+ s.binV4 = &binHandle{path: filepath.Join(dataDir, "IP2LOCATION-LITE-DB3.BIN")}
+ s.binV6 = &binHandle{path: filepath.Join(dataDir, "IP2LOCATION-LITE-DB3.IPV6.BIN")}
+ s.asn = &mmdbHandle{path: filepath.Join(dataDir, "GeoLite2-ASN.mmdb")}
+ s.country = &mmdbHandle{path: filepath.Join(dataDir, "GeoLite2-Country.mmdb")}
+ s.openAll()
+ return s
+}
+
+func (s *geoIPSuite) openAll() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ openBIN(s.binV4)
+ openBIN(s.binV6)
+ openMMDB(s.asn)
+ openMMDB(s.country)
+}
+
+func openBIN(h *binHandle) {
+ if h == nil {
+ return
+ }
+ st, err := os.Stat(h.path)
+ if err != nil {
+ closeBIN(h)
+ return
+ }
+ if h.db != nil && st.ModTime().Equal(h.mod) {
+ return
+ }
+ db, err := ip2location.OpenDB(h.path)
+ if err != nil {
+ closeBIN(h)
+ return
+ }
+ closeBIN(h)
+ h.db = db
+ h.mod = st.ModTime()
+}
+
+func closeBIN(h *binHandle) {
+ if h != nil && h.db != nil {
+ h.db.Close()
+ h.db = nil
+ }
+}
+
+func openMMDB(h *mmdbHandle) {
+ if h == nil {
+ return
+ }
+ st, err := os.Stat(h.path)
+ if err != nil {
+ closeMMDB(h)
+ return
+ }
+ if h.db != nil && st.ModTime().Equal(h.mod) {
+ return
+ }
+ db, err := maxminddb.Open(h.path)
+ if err != nil {
+ closeMMDB(h)
+ return
+ }
+ closeMMDB(h)
+ h.db = db
+ h.mod = st.ModTime()
+}
+
+func closeMMDB(h *mmdbHandle) {
+ if h != nil && h.db != nil {
+ _ = h.db.Close()
+ h.db = nil
+ }
+}
+
+func (s *geoIPSuite) ReloadIfNeeded() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ reloadBIN(s.binV4)
+ reloadBIN(s.binV6)
+ reloadMMDB(s.asn)
+ reloadMMDB(s.country)
+}
+
+func reloadBIN(h *binHandle) {
+ if h == nil {
+ return
+ }
+ st, err := os.Stat(h.path)
+ if err != nil {
+ closeBIN(h)
+ return
+ }
+ if h.db != nil && st.ModTime().Equal(h.mod) {
+ return
+ }
+ openBIN(h)
+}
+
+func reloadMMDB(h *mmdbHandle) {
+ if h == nil {
+ return
+ }
+ st, err := os.Stat(h.path)
+ if err != nil {
+ closeMMDB(h)
+ return
+ }
+ if h.db != nil && st.ModTime().Equal(h.mod) {
+ return
+ }
+ openMMDB(h)
+}
+
+func (s *geoIPSuite) Close() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ closeBIN(s.binV4)
+ closeBIN(s.binV6)
+ closeMMDB(s.asn)
+ closeMMDB(s.country)
+}
+
+func (s *geoIPSuite) BINV4Available() bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.binV4 != nil && s.binV4.db != nil
+}
+
+func (s *geoIPSuite) BINV6Available() bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.binV6 != nil && s.binV6.db != nil
+}
+
+func (s *geoIPSuite) ASNAvailable() bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.asn != nil && s.asn.db != nil
+}
+
+func (s *geoIPSuite) CountryAvailable() bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.country != nil && s.country.db != nil
+}
+
+func (s *geoIPSuite) AnyAvailable() bool {
+ return s.BINV4Available() || s.BINV6Available() || s.ASNAvailable() || s.CountryAvailable()
+}
+
+// Paths 返回 v4 BIN、v6 BIN、ASN、Country 兜底库路径
+func (s *geoIPSuite) Paths() (v4, v6, asn, country string) {
+ return filepath.Join(s.dataDir, "IP2LOCATION-LITE-DB3.BIN"),
+ filepath.Join(s.dataDir, "IP2LOCATION-LITE-DB3.IPV6.BIN"),
+ filepath.Join(s.dataDir, "GeoLite2-ASN.mmdb"),
+ filepath.Join(s.dataDir, "GeoLite2-Country.mmdb")
+}
+
+type geoCountryOnlyRecord struct {
+ Country struct {
+ ISOCode string `maxminddb:"iso_code"`
+ } `maxminddb:"country"`
+}
+
+type geoASNRecord struct {
+ AutonomousSystemNumber uint `maxminddb:"autonomous_system_number"`
+ AutonomousSystemOrganization string `maxminddb:"autonomous_system_organization"`
+}
+
+// Lookup 解析 IP 地理与 ASN(BIN 负责国家/省/市,ASN 独立查询)
+func (s *geoIPSuite) Lookup(ipStr string) GeoInfo {
+ var out GeoInfo
+ ip := net.ParseIP(ipStr)
+ if ip == nil || s == nil {
+ return out
+ }
+
+ s.mu.RLock()
+ v4DB := (*ip2location.DB)(nil)
+ v6DB := (*ip2location.DB)(nil)
+ asnDB := (*maxminddb.Reader)(nil)
+ countryDB := (*maxminddb.Reader)(nil)
+ if s.binV4 != nil {
+ v4DB = s.binV4.db
+ }
+ if s.binV6 != nil {
+ v6DB = s.binV6.db
+ }
+ if s.asn != nil {
+ asnDB = s.asn.db
+ }
+ if s.country != nil {
+ countryDB = s.country.db
+ }
+ s.mu.RUnlock()
+
+ binDB := v6DB
+ if ip.To4() != nil {
+ binDB = v4DB
+ }
+ if binDB != nil {
+ rec, err := binDB.Get_all(ipStr)
+ if err == nil {
+ out.Country = normalizeCountryCode(rec.Country_short)
+ out.Region = strings.TrimSpace(rec.Region)
+ out.City = strings.TrimSpace(rec.City)
+ }
+ }
+
+ if out.Country == "" && countryDB != nil {
+ var rec geoCountryOnlyRecord
+ if err := countryDB.Lookup(ip, &rec); err == nil {
+ out.Country = normalizeCountryCode(rec.Country.ISOCode)
+ }
+ }
+
+ if asnDB != nil {
+ var rec geoASNRecord
+ if err := asnDB.Lookup(ip, &rec); err == nil {
+ out.ASN = rec.AutonomousSystemNumber
+ out.ASOrg = strings.TrimSpace(rec.AutonomousSystemOrganization)
+ }
+ }
+
+ ApplyGeoZh(&out)
+ return out
+}
+
+// GeoIPService 对外暴露的 Geo 查询(诊断命令等)
+type GeoIPService struct {
+ inner *geoIPSuite
+}
+
+// NewGeoIPService 打开数据目录下的 BIN/MMDB
+func NewGeoIPService(dataDir string) *GeoIPService {
+ return &GeoIPService{inner: newGeoIPSuite(dataDir)}
+}
+
+func (s *GeoIPService) Close() {
+ if s != nil && s.inner != nil {
+ s.inner.Close()
+ }
+}
+
+func (s *GeoIPService) Lookup(ip string) GeoInfo {
+ if s == nil || s.inner == nil {
+ return GeoInfo{}
+ }
+ return s.inner.Lookup(ip)
+}
+
+func (s *GeoIPService) Paths() (v4, v6, asn, country string) {
+ if s == nil || s.inner == nil {
+ return "", "", "", ""
+ }
+ return s.inner.Paths()
+}
+
+func (s *GeoIPService) BINV4Available() bool {
+ if s == nil || s.inner == nil {
+ return false
+ }
+ return s.inner.BINV4Available()
+}
+
+func (s *GeoIPService) BINV6Available() bool {
+ if s == nil || s.inner == nil {
+ return false
+ }
+ return s.inner.BINV6Available()
+}
+
+func (s *GeoIPService) ASNAvailable() bool {
+ if s == nil || s.inner == nil {
+ return false
+ }
+ return s.inner.ASNAvailable()
+}
+
+func (s *GeoIPService) CountryAvailable() bool {
+ if s == nil || s.inner == nil {
+ return false
+ }
+ return s.inner.CountryAvailable()
+}
diff --git a/service/monitor.go b/service/monitor.go
new file mode 100644
index 0000000..82b727c
--- /dev/null
+++ b/service/monitor.go
@@ -0,0 +1,1225 @@
+package service
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "git.iioio.com/freefire/jiang13-forum/model"
+ "gorm.io/gorm"
+)
+
+const (
+ monitorFlushSize = 64
+ monitorFlushInterval = 2 * time.Second
+ monitorCleanupEvery = 1 * time.Hour
+ monitorQueueMax = 8192
+)
+
+// AccessLogLite 中间件入队用的轻量访问日志(不含 Geo)
+type AccessLogLite struct {
+ CreatedAt time.Time
+ Method string
+ Path string
+ Status int
+ Bytes int64
+ DurationMs int
+ IP string
+ UA string
+ Referer string
+ CDNCountry string // 仅 CDN 头,非 BIN 查询
+ IsBot bool
+}
+
+// accessLogJSON 写入 JSONL 的单行结构
+type accessLogJSON struct {
+ T string `json:"t"`
+ Method string `json:"method"`
+ Path string `json:"path"`
+ Status int `json:"status"`
+ Bytes int64 `json:"bytes"`
+ DurationMs int `json:"duration_ms"`
+ IP string `json:"ip"`
+ UA string `json:"ua"`
+ Referer string `json:"referer"`
+ Country string `json:"country"`
+ Region string `json:"region"`
+ RegionISO string `json:"region_iso"`
+ City string `json:"city"`
+ ASN uint `json:"asn"`
+ ASOrg string `json:"as_org"`
+ IsBot bool `json:"is_bot"`
+}
+
+type monitorDayStats struct {
+ dayKey string
+ requests int64
+ traffic int64
+ bots int64
+ status4xx int64
+ status5xx int64
+ uniqueIPs map[string]struct{}
+ statusCounts map[int]int64
+}
+
+type monitorMinuteBucket struct {
+ count int64
+ bytes int64
+}
+
+// MonitorService 网站监控:JSONL 请求日志 + 独立 monitor.db pageview
+type MonitorService struct {
+ settings *ForumSettingsService
+ dataDir string
+ accessLogDir string
+
+ mu sync.Mutex
+ queue []AccessLogLite
+ stopCh chan struct{}
+ wg sync.WaitGroup
+
+ geo *geoIPSuite
+
+ dayMu sync.RWMutex
+ day monitorDayStats
+
+ rtMu sync.RWMutex
+ rtMinute map[string]monitorMinuteBucket // key: 2006-01-02 15:04
+}
+
+// NewMonitorService 创建监控服务
+func NewMonitorService(settings *ForumSettingsService, dataDir, _ string) *MonitorService {
+ accessDir := filepath.Join(dataDir, "logs", "access")
+ _ = os.MkdirAll(accessDir, 0o755)
+ now := time.Now()
+ dayKey := now.Format("2006-01-02")
+ return &MonitorService{
+ settings: settings,
+ dataDir: dataDir,
+ accessLogDir: accessDir,
+ queue: make([]AccessLogLite, 0, monitorFlushSize),
+ stopCh: make(chan struct{}),
+ geo: newGeoIPSuite(dataDir),
+ day: monitorDayStats{
+ dayKey: dayKey,
+ uniqueIPs: map[string]struct{}{},
+ statusCounts: map[int]int64{},
+ },
+ rtMinute: map[string]monitorMinuteBucket{},
+ }
+}
+
+// StartBackground 启动刷盘与清理协程
+func (m *MonitorService) StartBackground() {
+ m.wg.Add(2)
+ go m.flushLoop()
+ go m.cleanupLoop()
+}
+
+// Enabled 采集是否开启
+func (m *MonitorService) Enabled() bool {
+ return m != nil && m.settings != nil && m.settings.MonitorEnabled()
+}
+
+// Stop 停止后台任务并刷盘
+func (m *MonitorService) Stop() {
+ close(m.stopCh)
+ m.wg.Wait()
+ m.flush()
+ if m.geo != nil {
+ m.geo.Close()
+ }
+}
+
+func (m *MonitorService) flushLoop() {
+ defer m.wg.Done()
+ t := time.NewTicker(monitorFlushInterval)
+ defer t.Stop()
+ for {
+ select {
+ case <-m.stopCh:
+ return
+ case <-t.C:
+ m.flush()
+ }
+ }
+}
+
+func (m *MonitorService) cleanupLoop() {
+ defer m.wg.Done()
+ t := time.NewTicker(monitorCleanupEvery)
+ defer t.Stop()
+ m.PurgeExpired()
+ for {
+ select {
+ case <-m.stopCh:
+ return
+ case <-t.C:
+ m.PurgeExpired()
+ if m.geo != nil {
+ m.geo.ReloadIfNeeded()
+ }
+ }
+ }
+}
+
+// Enqueue 缓冲一条访问日志(中间件调用;队列满则丢弃新日志)
+func (m *MonitorService) Enqueue(row AccessLogLite) {
+ if m == nil || !m.settings.MonitorEnabled() {
+ return
+ }
+ m.mu.Lock()
+ if len(m.queue) >= monitorQueueMax {
+ m.mu.Unlock()
+ return
+ }
+ m.queue = append(m.queue, row)
+ needFlush := len(m.queue) >= monitorFlushSize
+ m.mu.Unlock()
+ if needFlush {
+ m.flush()
+ }
+}
+
+func (m *MonitorService) flush() {
+ m.mu.Lock()
+ if len(m.queue) == 0 {
+ m.mu.Unlock()
+ return
+ }
+ batch := m.queue
+ m.queue = make([]AccessLogLite, 0, monitorFlushSize)
+ m.mu.Unlock()
+
+ if len(batch) == 0 {
+ return
+ }
+
+ byDay := map[string][]accessLogJSON{}
+ for _, lite := range batch {
+ geo := m.resolveGeoLite(lite.IP, lite.CDNCountry)
+ row := accessLogJSON{
+ T: lite.CreatedAt.Format(time.RFC3339),
+ Method: lite.Method,
+ Path: lite.Path,
+ Status: lite.Status,
+ Bytes: lite.Bytes,
+ DurationMs: lite.DurationMs,
+ IP: lite.IP,
+ UA: lite.UA,
+ Referer: lite.Referer,
+ Country: geo.Country,
+ Region: geo.Region,
+ RegionISO: geo.RegionISO,
+ City: geo.City,
+ ASN: geo.ASN,
+ ASOrg: geo.ASOrg,
+ IsBot: lite.IsBot,
+ }
+ day := lite.CreatedAt.Local().Format("2006-01-02")
+ byDay[day] = append(byDay[day], row)
+ m.updateCounters(lite, geo)
+ }
+
+ for day, rows := range byDay {
+ m.appendJSONL(day, rows)
+ }
+}
+
+func (m *MonitorService) appendJSONL(day string, rows []accessLogJSON) {
+ if len(rows) == 0 {
+ return
+ }
+ path := filepath.Join(m.accessLogDir, day+".jsonl")
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
+ if err != nil {
+ return
+ }
+ defer f.Close()
+ enc := json.NewEncoder(f)
+ for _, row := range rows {
+ _ = enc.Encode(row)
+ }
+}
+
+func (m *MonitorService) ensureDay(now time.Time) {
+ key := now.Local().Format("2006-01-02")
+ m.dayMu.Lock()
+ defer m.dayMu.Unlock()
+ if m.day.dayKey == key {
+ return
+ }
+ m.day = monitorDayStats{
+ dayKey: key,
+ uniqueIPs: map[string]struct{}{},
+ statusCounts: map[int]int64{},
+ }
+}
+
+func (m *MonitorService) updateCounters(lite AccessLogLite, _ GeoInfo) {
+ m.ensureDay(lite.CreatedAt)
+ minKey := lite.CreatedAt.Local().Truncate(time.Minute).Format("2006-01-02 15:04")
+
+ m.dayMu.Lock()
+ if lite.CreatedAt.Local().Format("2006-01-02") == m.day.dayKey {
+ m.day.requests++
+ m.day.traffic += lite.Bytes
+ if lite.IsBot {
+ m.day.bots++
+ }
+ if lite.Status >= 400 && lite.Status < 500 {
+ m.day.status4xx++
+ }
+ if lite.Status >= 500 {
+ m.day.status5xx++
+ }
+ if ip := strings.TrimSpace(lite.IP); ip != "" {
+ m.day.uniqueIPs[ip] = struct{}{}
+ }
+ m.day.statusCounts[lite.Status]++
+ }
+ m.dayMu.Unlock()
+
+ m.rtMu.Lock()
+ b := m.rtMinute[minKey]
+ b.count++
+ b.bytes += lite.Bytes
+ m.rtMinute[minKey] = b
+ // 清理 2 小时前的分钟桶
+ cutoff := time.Now().Add(-2 * time.Hour).Truncate(time.Minute)
+ for k := range m.rtMinute {
+ t, err := time.ParseInLocation("2006-01-02 15:04", k, time.Local)
+ if err != nil || t.Before(cutoff) {
+ delete(m.rtMinute, k)
+ }
+ }
+ m.rtMu.Unlock()
+}
+
+// pageViewDB 浏览量独立库;未初始化时返回 nil
+func pageViewDB() *gorm.DB {
+ return model.MonitorDB
+}
+
+// PurgeExpired 按保留天数删除过期 pageview 与 jsonl 请求日志
+func (m *MonitorService) PurgeExpired() {
+ if m == nil || m.settings == nil {
+ return
+ }
+ cfg := m.settings.MonitorConfig()
+ pvCutoff := time.Now().AddDate(0, 0, -cfg.RetentionDays)
+ if db := pageViewDB(); db != nil {
+ _ = db.Where("created_at < ?", pvCutoff).Delete(&model.PageView{}).Error
+ }
+ m.purgeOldJSONL(cfg.AccessLogRetentionDays)
+}
+
+func (m *MonitorService) purgeOldJSONL(retentionDays int) {
+ if retentionDays < 1 {
+ retentionDays = 1
+ }
+ cutoff := time.Now().AddDate(0, 0, -retentionDays)
+ entries, err := os.ReadDir(m.accessLogDir)
+ if err != nil {
+ return
+ }
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
+ continue
+ }
+ base := strings.TrimSuffix(e.Name(), ".jsonl")
+ day, err := time.ParseInLocation("2006-01-02", base, time.Local)
+ if err != nil {
+ continue
+ }
+ if day.Before(cutoff) {
+ _ = os.Remove(filepath.Join(m.accessLogDir, e.Name()))
+ }
+ }
+}
+
+// ShouldSkip 是否按排除规则跳过
+func (m *MonitorService) ShouldSkip(path string) bool {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return true
+ }
+ lower := strings.ToLower(path)
+ if lower == "/api/monitor/pageview" || strings.HasPrefix(lower, "/api/monitor/pageview?") {
+ return true
+ }
+ for _, rule := range m.settings.MonitorConfig().ExcludeRules {
+ rule = strings.TrimSpace(rule)
+ if rule == "" {
+ continue
+ }
+ r := strings.ToLower(rule)
+ if strings.HasPrefix(r, ".") {
+ if strings.HasSuffix(lower, r) {
+ return true
+ }
+ continue
+ }
+ if strings.HasPrefix(lower, r) || lower == strings.TrimSuffix(r, "/") {
+ return true
+ }
+ }
+ return false
+}
+
+// ResolveClientIP 解析客户端 IP(可选信任代理头)
+func (m *MonitorService) ResolveClientIP(r *http.Request, remoteAddr string) string {
+ if m.settings.MonitorConfig().TrustProxy {
+ for _, h := range []string{"CF-Connecting-IP", "True-Client-IP", "X-Real-IP"} {
+ if v := strings.TrimSpace(r.Header.Get(h)); v != "" {
+ if ip := firstIP(v); ip != "" {
+ return ip
+ }
+ }
+ }
+ if v := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); v != "" {
+ if ip := firstIP(v); ip != "" {
+ return ip
+ }
+ }
+ }
+ host, _, err := net.SplitHostPort(remoteAddr)
+ if err == nil {
+ return host
+ }
+ return strings.TrimSpace(remoteAddr)
+}
+
+func firstIP(v string) string {
+ parts := strings.Split(v, ",")
+ if len(parts) == 0 {
+ return ""
+ }
+ ip := strings.TrimSpace(parts[0])
+ if net.ParseIP(ip) == nil {
+ return ""
+ }
+ return ip
+}
+
+func cdnCountryFromRequest(r *http.Request) string {
+ if r == nil {
+ return ""
+ }
+ for _, h := range []string{"CF-IPCountry", "CloudFront-Viewer-Country", "X-Country-Code", "X-AppEngine-Country"} {
+ if v := normalizeCountryCode(r.Header.Get(h)); v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+// ResolveGeo 解析地理与 ASN:CDN 头可补全国家
+func (m *MonitorService) ResolveGeo(r *http.Request, ip string) GeoInfo {
+ cdn := cdnCountryFromRequest(r)
+ return m.resolveGeoLite(ip, cdn)
+}
+
+func (m *MonitorService) resolveGeoLite(ip, cdnCountry string) GeoInfo {
+ var out GeoInfo
+ if m != nil && m.geo != nil {
+ out = m.geo.Lookup(ip)
+ }
+ if out.Country == "" && cdnCountry != "" {
+ out.Country = normalizeCountryCode(cdnCountry)
+ ApplyGeoZh(&out)
+ }
+ return out
+}
+
+func normalizeCountryCode(v string) string {
+ v = strings.ToUpper(strings.TrimSpace(v))
+ if len(v) != 2 || v == "XX" || v == "T1" {
+ return ""
+ }
+ for _, c := range v {
+ if c < 'A' || c > 'Z' {
+ return ""
+ }
+ }
+ return v
+}
+
+// EnrichGeoMeta 填充设置中的 Geo 库与访问日志目录状态
+func (m *MonitorService) EnrichGeoMeta(cfg *MonitorConfig) {
+ if cfg == nil || m == nil {
+ return
+ }
+ cfg.AccessLogDir = m.accessLogDir
+ cfg.DefaultExcludeRules = DefaultMonitorExcludeRules()
+ if m.settings != nil {
+ cfg.AccessLogRetentionDays = m.settings.MonitorConfig().AccessLogRetentionDays
+ }
+ v4, v6, asn, country := "", "", "", ""
+ if m.geo != nil {
+ v4, v6, asn, country = m.geo.Paths()
+ cfg.IP2LocationV4Available = m.geo.BINV4Available()
+ cfg.IP2LocationV6Available = m.geo.BINV6Available()
+ cfg.GeoIPASNAvailable = m.geo.ASNAvailable()
+ cfg.GeoIPCountryAvailable = m.geo.CountryAvailable()
+ cfg.GeoIPAvailable = m.geo.AnyAvailable()
+ }
+ cfg.IP2LocationV4Path = v4
+ cfg.IP2LocationV6Path = v6
+ cfg.GeoIPASNPath = asn
+ cfg.GeoIPCountryPath = country
+}
+
+// MonitorOverview 今日概览
+type MonitorOverview struct {
+ Enabled bool `json:"enabled"`
+ Pageviews int64 `json:"pageviews"`
+ Visitors int64 `json:"visitors"`
+ UniqueIPs int64 `json:"unique_ips"`
+ Traffic int64 `json:"traffic"`
+ Bots int64 `json:"bots"`
+ Requests int64 `json:"requests"`
+ Status4xx int64 `json:"status_4xx"`
+ Status5xx int64 `json:"status_5xx"`
+}
+
+// MonitorGeoItem 国家排行
+type MonitorGeoItem struct {
+ Country string `json:"country"`
+ Count int64 `json:"count"`
+}
+
+// MonitorRegionItem 省/州排行
+type MonitorRegionItem struct {
+ Country string `json:"country"`
+ Region string `json:"region"`
+ RegionISO string `json:"region_iso"`
+ Count int64 `json:"count"`
+}
+
+// MonitorCityItem 城市排行
+type MonitorCityItem struct {
+ Country string `json:"country"`
+ Region string `json:"region"`
+ City string `json:"city"`
+ Count int64 `json:"count"`
+}
+
+// MonitorASNItem 运营商(ASN)排行
+type MonitorASNItem struct {
+ ASN uint `json:"asn"`
+ ASOrg string `json:"as_org"`
+ Count int64 `json:"count"`
+}
+
+// MonitorGeoResult 地理分布
+type MonitorGeoResult struct {
+ Range string `json:"range"`
+ Countries []MonitorGeoItem `json:"countries"`
+ Regions []MonitorRegionItem `json:"regions"`
+ Cities []MonitorCityItem `json:"cities"`
+ ASNs []MonitorASNItem `json:"asns"`
+ HasData bool `json:"has_data"`
+}
+
+// MonitorStatItem 维度排行项
+type MonitorStatItem struct {
+ Key string `json:"key"`
+ Count int64 `json:"count"`
+}
+
+// MonitorRealtime 实时指标
+type MonitorRealtime struct {
+ Enabled bool `json:"enabled"`
+ Requests1m int64 `json:"requests_1m"`
+ Traffic1m int64 `json:"traffic_1m"`
+ HourlySeries []MonitorRealtimePoint `json:"hourly_series"`
+}
+
+// MonitorRealtimePoint 近 1 小时分钟点
+type MonitorRealtimePoint struct {
+ Minute string `json:"minute"`
+ Count int64 `json:"count"`
+ Bytes int64 `json:"bytes"`
+}
+
+// MonitorLogItem 请求日志行
+type MonitorLogItem struct {
+ ID uint `json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ Method string `json:"method"`
+ Path string `json:"path"`
+ Status int `json:"status"`
+ Bytes int64 `json:"bytes"`
+ DurationMs int `json:"duration_ms"`
+ IP string `json:"ip"`
+ UA string `json:"ua"`
+ Referer string `json:"referer"`
+ Country string `json:"country"`
+ Region string `json:"region"`
+ City string `json:"city"`
+ ASN uint `json:"asn"`
+ ASOrg string `json:"as_org"`
+ IsBot bool `json:"is_bot"`
+}
+
+func startOfLocalDay(t time.Time) time.Time {
+ y, m, d := t.Local().Date()
+ return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
+}
+
+// DashboardTraffic 仪表盘流量摘要(page_views,非 bot)
+type DashboardTraffic struct {
+ Enabled bool `json:"enabled"`
+ TodayPV int64 `json:"today_pv"`
+ TodayUV int64 `json:"today_uv"`
+ YesterdayPV int64 `json:"yesterday_pv"`
+ TotalPV int64 `json:"total_pv"`
+}
+
+// DashboardTraffic 聚合今日/昨日/累计浏览量
+func (m *MonitorService) DashboardTraffic() DashboardTraffic {
+ out := DashboardTraffic{Enabled: m != nil && m.settings != nil && m.settings.MonitorEnabled()}
+ if m == nil {
+ return out
+ }
+ db := pageViewDB()
+ if db == nil {
+ return out
+ }
+ now := time.Now()
+ today := startOfLocalDay(now)
+ yesterday := today.AddDate(0, 0, -1)
+ _ = db.Model(&model.PageView{}).Where("created_at >= ? AND is_bot = ?", today, false).Count(&out.TodayPV).Error
+ _ = db.Model(&model.PageView{}).Where("created_at >= ? AND is_bot = ? AND ip <> ''", today, false).
+ Distinct("ip").Count(&out.TodayUV).Error
+ _ = db.Model(&model.PageView{}).
+ Where("created_at >= ? AND created_at < ? AND is_bot = ?", yesterday, today, false).
+ Count(&out.YesterdayPV).Error
+ _ = db.Model(&model.PageView{}).Where("is_bot = ?", false).Count(&out.TotalPV).Error
+ return out
+}
+
+// PageViewInput 前台信标入参
+type PageViewInput struct {
+ Path string `json:"path"`
+ Referrer string `json:"referrer"`
+}
+
+// NormalizePageViewPath 校验并规范化前端路径
+func NormalizePageViewPath(raw string) (string, bool) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || !strings.HasPrefix(raw, "/") {
+ return "", false
+ }
+ if strings.Contains(raw, "://") || strings.Contains(raw, "\\") {
+ return "", false
+ }
+ if i := strings.IndexByte(raw, '#'); i >= 0 {
+ raw = raw[:i]
+ }
+ if len(raw) > 512 {
+ raw = raw[:512]
+ }
+ pathOnly := raw
+ if i := strings.IndexByte(raw, '?'); i >= 0 {
+ pathOnly = raw[:i]
+ }
+ if shouldIgnorePageViewPath(pathOnly) {
+ return "", false
+ }
+ return raw, true
+}
+
+func shouldIgnorePageViewPath(path string) bool {
+ lower := strings.ToLower(path)
+ prefixes := []string{
+ "/admin", "/login", "/register", "/forgot-password",
+ "/oauth", "/api", "/health", "/uploads", "/media",
+ }
+ for _, p := range prefixes {
+ if lower == p || strings.HasPrefix(lower, p+"/") {
+ return true
+ }
+ }
+ return false
+}
+
+// RecordPageView 写入一条 SPA 浏览记录
+func (m *MonitorService) RecordPageView(r *http.Request, remoteAddr string, in PageViewInput) error {
+ if m == nil || !m.settings.MonitorEnabled() {
+ return nil
+ }
+ db := pageViewDB()
+ if db == nil {
+ return nil
+ }
+ path, ok := NormalizePageViewPath(in.Path)
+ if !ok {
+ return nil
+ }
+ ref := strings.TrimSpace(in.Referrer)
+ if strings.Contains(ref, "://") {
+ if len(ref) > 512 {
+ ref = ref[:512]
+ }
+ } else if ref != "" && !strings.HasPrefix(ref, "/") {
+ ref = ""
+ }
+ if len(ref) > 512 {
+ ref = ref[:512]
+ }
+ ip := m.ResolveClientIP(r, remoteAddr)
+ ua := r.UserAgent()
+ if len(ua) > 512 {
+ ua = ua[:512]
+ }
+ geo := m.ResolveGeo(r, ip)
+ row := model.PageView{
+ CreatedAt: time.Now(),
+ Path: path,
+ Referrer: ref,
+ IP: ip,
+ UA: ua,
+ Country: geo.Country,
+ Region: geo.Region,
+ RegionISO: geo.RegionISO,
+ City: geo.City,
+ ASN: geo.ASN,
+ ASOrg: geo.ASOrg,
+ IsBot: IsSEOCrawler(ua) || isGenericBot(ua),
+ }
+ return db.Create(&row).Error
+}
+
+func parseMonitorRange(rangeKey string) (time.Time, string) {
+ now := time.Now()
+ switch strings.ToLower(strings.TrimSpace(rangeKey)) {
+ case "7d":
+ return now.AddDate(0, 0, -7), "7d"
+ case "90d":
+ return now.AddDate(0, 0, -90), "90d"
+ case "1d", "today":
+ return startOfLocalDay(now), "1d"
+ default:
+ return now.AddDate(0, 0, -30), "30d"
+ }
+}
+
+// OverviewToday 今日指标(请求类来自内存 + JSONL 口径;浏览量来自 page_views)
+func (m *MonitorService) OverviewToday() MonitorOverview {
+ out := MonitorOverview{Enabled: m.settings.MonitorEnabled()}
+ m.ensureDay(time.Now())
+
+ m.dayMu.RLock()
+ if m.day.dayKey == time.Now().Local().Format("2006-01-02") {
+ out.Requests = m.day.requests
+ out.Traffic = m.day.traffic
+ out.Bots = m.day.bots
+ out.Status4xx = m.day.status4xx
+ out.Status5xx = m.day.status5xx
+ out.UniqueIPs = int64(len(m.day.uniqueIPs))
+ }
+ m.dayMu.RUnlock()
+
+ db := pageViewDB()
+ if db == nil {
+ return out
+ }
+ start := startOfLocalDay(time.Now())
+ _ = db.Model(&model.PageView{}).Where("created_at >= ? AND is_bot = ?", start, false).Count(&out.Pageviews).Error
+ _ = db.Model(&model.PageView{}).Where("created_at >= ? AND is_bot = ? AND ip <> ''", start, false).
+ Distinct("ip").Count(&out.Visitors).Error
+ return out
+}
+
+// GeoStats 地理分布(基于 pageview)
+func (m *MonitorService) GeoStats(rangeKey string) MonitorGeoResult {
+ since, rk := parseMonitorRange(rangeKey)
+ out := MonitorGeoResult{
+ Range: rk,
+ Countries: []MonitorGeoItem{},
+ Regions: []MonitorRegionItem{},
+ Cities: []MonitorCityItem{},
+ ASNs: []MonitorASNItem{},
+ }
+ db := pageViewDB()
+ if db == nil {
+ return out
+ }
+
+ type countryRow struct {
+ Country string
+ Count int64
+ }
+ var countries []countryRow
+ _ = db.Model(&model.PageView{}).
+ Select("country, COUNT(*) as count").
+ Where("created_at >= ? AND country <> '' AND is_bot = ?", since, false).
+ Group("country").
+ Order("count DESC").
+ Limit(50).
+ Scan(&countries).Error
+ for _, r := range countries {
+ out.Countries = append(out.Countries, MonitorGeoItem{Country: r.Country, Count: r.Count})
+ }
+
+ type regionRow struct {
+ Country string
+ Region string
+ RegionISO string
+ Count int64
+ }
+ var regions []regionRow
+ _ = db.Model(&model.PageView{}).
+ Select("country, region, region_iso, COUNT(*) as count").
+ Where("created_at >= ? AND country <> '' AND (region <> '' OR region_iso <> '') AND is_bot = ?", since, false).
+ Group("country, region, region_iso").
+ Order("count DESC").
+ Limit(80).
+ Scan(®ions).Error
+ for _, r := range regions {
+ out.Regions = append(out.Regions, MonitorRegionItem{
+ Country: r.Country, Region: r.Region, RegionISO: r.RegionISO, Count: r.Count,
+ })
+ }
+
+ type cityRow struct {
+ Country string
+ Region string
+ City string
+ Count int64
+ }
+ var cities []cityRow
+ _ = db.Model(&model.PageView{}).
+ Select("country, region, city, COUNT(*) as count").
+ Where("created_at >= ? AND city <> '' AND is_bot = ?", since, false).
+ Group("country, region, city").
+ Order("count DESC").
+ Limit(50).
+ Scan(&cities).Error
+ for _, r := range cities {
+ out.Cities = append(out.Cities, MonitorCityItem{
+ Country: r.Country, Region: r.Region, City: r.City, Count: r.Count,
+ })
+ }
+
+ type asnRow struct {
+ ASN uint
+ ASOrg string
+ Count int64
+ }
+ var asns []asnRow
+ _ = db.Model(&model.PageView{}).
+ Select("asn, as_org, COUNT(*) as count").
+ Where("created_at >= ? AND asn > 0 AND is_bot = ?", since, false).
+ Group("asn, as_org").
+ Order("count DESC").
+ Limit(50).
+ Scan(&asns).Error
+ for _, r := range asns {
+ out.ASNs = append(out.ASNs, MonitorASNItem{ASN: r.ASN, ASOrg: r.ASOrg, Count: r.Count})
+ }
+
+ out.HasData = len(out.Countries) > 0 || len(out.Regions) > 0 || len(out.Cities) > 0 || len(out.ASNs) > 0
+ return out
+}
+
+// DimStats 维度排行
+func (m *MonitorService) DimStats(dim, rangeKey string) []MonitorStatItem {
+ since, rk := parseMonitorRange(rangeKey)
+ dim = strings.ToLower(strings.TrimSpace(dim))
+ out := []MonitorStatItem{}
+ db := pageViewDB()
+
+ switch dim {
+ case "url", "path":
+ if db == nil {
+ return out
+ }
+ type row struct {
+ Path string
+ Count int64
+ }
+ var rows []row
+ _ = db.Model(&model.PageView{}).
+ Select("path, COUNT(*) as count").
+ Where("created_at >= ?", since).
+ Group("path").Order("count DESC").Limit(50).Scan(&rows).Error
+ for _, r := range rows {
+ out = append(out, MonitorStatItem{Key: r.Path, Count: r.Count})
+ }
+ case "referer", "referrer":
+ if db == nil {
+ return out
+ }
+ type row struct {
+ Referrer string
+ Count int64
+ }
+ var rows []row
+ _ = db.Model(&model.PageView{}).
+ Select("CASE WHEN referrer = '' THEN '(直接访问)' ELSE referrer END as referrer, COUNT(*) as count").
+ Where("created_at >= ?", since).
+ Group("referrer").Order("count DESC").Limit(50).Scan(&rows).Error
+ for _, r := range rows {
+ out = append(out, MonitorStatItem{Key: r.Referrer, Count: r.Count})
+ }
+ case "status":
+ if rk == "1d" {
+ m.ensureDay(time.Now())
+ m.dayMu.RLock()
+ if m.day.dayKey == time.Now().Local().Format("2006-01-02") {
+ type kv struct {
+ status int
+ count int64
+ }
+ list := make([]kv, 0, len(m.day.statusCounts))
+ for st, c := range m.day.statusCounts {
+ list = append(list, kv{st, c})
+ }
+ sort.Slice(list, func(i, j int) bool { return list[i].count > list[j].count })
+ n := len(list)
+ if n > 50 {
+ n = 50
+ }
+ for i := 0; i < n; i++ {
+ out = append(out, MonitorStatItem{Key: fmt.Sprintf("%d", list[i].status), Count: list[i].count})
+ }
+ }
+ m.dayMu.RUnlock()
+ }
+ if len(out) == 0 {
+ counts := m.scanJSONLStatusCounts(since)
+ type kv struct {
+ k string
+ c int64
+ }
+ list := make([]kv, 0, len(counts))
+ for st, c := range counts {
+ list = append(list, kv{fmt.Sprintf("%d", st), c})
+ }
+ sort.Slice(list, func(i, j int) bool { return list[i].c > list[j].c })
+ n := len(list)
+ if n > 50 {
+ n = 50
+ }
+ for i := 0; i < n; i++ {
+ out = append(out, MonitorStatItem{Key: list[i].k, Count: list[i].c})
+ }
+ }
+ case "browser", "os", "device":
+ if db == nil {
+ return out
+ }
+ var uas []string
+ _ = db.Model(&model.PageView{}).
+ Where("created_at >= ?", since).
+ Limit(20000).
+ Pluck("ua", &uas).Error
+ counts := map[string]int64{}
+ for _, ua := range uas {
+ key := classifyUA(ua, dim)
+ counts[key]++
+ }
+ type kv struct {
+ k string
+ c int64
+ }
+ list := make([]kv, 0, len(counts))
+ for k, c := range counts {
+ list = append(list, kv{k, c})
+ }
+ sort.Slice(list, func(i, j int) bool { return list[i].c > list[j].c })
+ n := len(list)
+ if n > 50 {
+ n = 50
+ }
+ for i := 0; i < n; i++ {
+ out = append(out, MonitorStatItem{Key: list[i].k, Count: list[i].c})
+ }
+ default:
+ return m.DimStats("url", rangeKey)
+ }
+ return out
+}
+
+func (m *MonitorService) scanJSONLStatusCounts(since time.Time) map[int]int64 {
+ counts := map[int]int64{}
+ for _, path := range m.listJSONLFilesSince(since) {
+ rows, _ := m.readJSONLFile(path)
+ for _, row := range rows {
+ t, err := time.Parse(time.RFC3339, row.T)
+ if err != nil || t.Before(since) {
+ continue
+ }
+ counts[row.Status]++
+ }
+ }
+ return counts
+}
+
+func classifyUA(ua, dim string) string {
+ l := strings.ToLower(ua)
+ if l == "" {
+ return "未知"
+ }
+ switch dim {
+ case "browser":
+ switch {
+ case strings.Contains(l, "edg/"):
+ return "Edge"
+ case strings.Contains(l, "chrome") && !strings.Contains(l, "edg"):
+ return "Chrome"
+ case strings.Contains(l, "firefox"):
+ return "Firefox"
+ case strings.Contains(l, "safari") && !strings.Contains(l, "chrome"):
+ return "Safari"
+ case strings.Contains(l, "msie") || strings.Contains(l, "trident"):
+ return "IE"
+ default:
+ return "其他"
+ }
+ case "os":
+ switch {
+ case strings.Contains(l, "windows"):
+ return "Windows"
+ case strings.Contains(l, "android"):
+ return "Android"
+ case strings.Contains(l, "iphone") || strings.Contains(l, "ipad") || strings.Contains(l, "ios"):
+ return "iOS"
+ case strings.Contains(l, "mac os") || strings.Contains(l, "macintosh"):
+ return "macOS"
+ case strings.Contains(l, "linux"):
+ return "Linux"
+ default:
+ return "其他"
+ }
+ default:
+ switch {
+ case strings.Contains(l, "mobile") || strings.Contains(l, "android") || strings.Contains(l, "iphone"):
+ return "Mobile"
+ case strings.Contains(l, "ipad") || strings.Contains(l, "tablet"):
+ return "Tablet"
+ default:
+ return "Desktop"
+ }
+ }
+}
+
+// Realtime 近 1 分钟 + 近 1 小时序列(内存环)
+func (m *MonitorService) Realtime() MonitorRealtime {
+ out := MonitorRealtime{
+ Enabled: m.settings.MonitorEnabled(),
+ HourlySeries: make([]MonitorRealtimePoint, 0, 60),
+ }
+ now := time.Now()
+ since1m := now.Add(-1 * time.Minute).Truncate(time.Minute)
+
+ m.rtMu.RLock()
+ for k, b := range m.rtMinute {
+ t, err := time.ParseInLocation("2006-01-02 15:04", k, time.Local)
+ if err != nil {
+ continue
+ }
+ if !t.Before(since1m) {
+ out.Requests1m += b.count
+ out.Traffic1m += b.bytes
+ }
+ }
+ byMin := map[string]monitorMinuteBucket{}
+ for k, b := range m.rtMinute {
+ byMin[k] = b
+ }
+ m.rtMu.RUnlock()
+
+ for i := 59; i >= 0; i-- {
+ t := now.Add(-time.Duration(i) * time.Minute).Truncate(time.Minute)
+ key := t.Format("2006-01-02 15:04")
+ pt := MonitorRealtimePoint{Minute: t.Format("15:04")}
+ if r, ok := byMin[key]; ok {
+ pt.Count = r.count
+ pt.Bytes = r.bytes
+ }
+ out.HourlySeries = append(out.HourlySeries, pt)
+ }
+ return out
+}
+
+// ListLogs 从 JSONL 分页筛选请求日志
+func (m *MonitorService) ListLogs(page, size int, method, path, status, ip string) (items []MonitorLogItem, total int64) {
+ if page < 1 {
+ page = 1
+ }
+ if size < 1 {
+ size = 20
+ }
+ if size > 100 {
+ size = 100
+ }
+ retention := m.settings.MonitorConfig().AccessLogRetentionDays
+ since := time.Now().AddDate(0, 0, -retention)
+
+ var all []accessLogJSON
+ for _, fp := range m.listJSONLFilesSince(since) {
+ rows, err := m.readJSONLFile(fp)
+ if err != nil {
+ continue
+ }
+ all = append(all, rows...)
+ }
+ // 新到旧
+ sort.Slice(all, func(i, j int) bool {
+ return all[i].T > all[j].T
+ })
+
+ method = strings.TrimSpace(strings.ToUpper(method))
+ path = strings.TrimSpace(path)
+ status = strings.TrimSpace(status)
+ ip = strings.TrimSpace(ip)
+
+ filtered := make([]accessLogJSON, 0, len(all))
+ for _, row := range all {
+ if method != "" && strings.ToUpper(row.Method) != method {
+ continue
+ }
+ if path != "" && !strings.Contains(row.Path, path) {
+ continue
+ }
+ if status != "" && !strings.HasPrefix(fmt.Sprintf("%d", row.Status), status) {
+ continue
+ }
+ if ip != "" && !strings.Contains(row.IP, ip) {
+ continue
+ }
+ filtered = append(filtered, row)
+ }
+
+ total = int64(len(filtered))
+ start := (page - 1) * size
+ if start >= len(filtered) {
+ return []MonitorLogItem{}, total
+ }
+ end := start + size
+ if end > len(filtered) {
+ end = len(filtered)
+ }
+ items = make([]MonitorLogItem, 0, end-start)
+ for i, row := range filtered[start:end] {
+ created, _ := time.Parse(time.RFC3339, row.T)
+ items = append(items, MonitorLogItem{
+ ID: uint(start + i + 1),
+ CreatedAt: created,
+ Method: row.Method,
+ Path: row.Path,
+ Status: row.Status,
+ Bytes: row.Bytes,
+ DurationMs: row.DurationMs,
+ IP: row.IP,
+ UA: row.UA,
+ Referer: row.Referer,
+ Country: row.Country,
+ Region: row.Region,
+ City: row.City,
+ ASN: row.ASN,
+ ASOrg: row.ASOrg,
+ IsBot: row.IsBot,
+ })
+ }
+ return items, total
+}
+
+func (m *MonitorService) listJSONLFilesSince(since time.Time) []string {
+ entries, err := os.ReadDir(m.accessLogDir)
+ if err != nil {
+ return nil
+ }
+ var files []string
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
+ continue
+ }
+ base := strings.TrimSuffix(e.Name(), ".jsonl")
+ day, err := time.ParseInLocation("2006-01-02", base, time.Local)
+ if err != nil {
+ continue
+ }
+ if day.Before(startOfLocalDay(since)) {
+ continue
+ }
+ files = append(files, filepath.Join(m.accessLogDir, e.Name()))
+ }
+ sort.Strings(files)
+ return files
+}
+
+func (m *MonitorService) readJSONLFile(path string) ([]accessLogJSON, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ var rows []accessLogJSON
+ sc := bufio.NewScanner(f)
+ sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ for sc.Scan() {
+ line := strings.TrimSpace(sc.Text())
+ if line == "" {
+ continue
+ }
+ var row accessLogJSON
+ if err := json.Unmarshal([]byte(line), &row); err != nil {
+ continue
+ }
+ rows = append(rows, row)
+ }
+ return rows, sc.Err()
+}
+
+// BuildAccessLog 从请求构造轻量日志(热路径不查 Geo)
+func (m *MonitorService) BuildAccessLog(r *http.Request, remoteAddr string, status int, bytes int64, durationMs int) AccessLogLite {
+ ip := m.ResolveClientIP(r, remoteAddr)
+ ua := r.UserAgent()
+ if len(ua) > 512 {
+ ua = ua[:512]
+ }
+ ref := r.Referer()
+ if len(ref) > 512 {
+ ref = ref[:512]
+ }
+ path := r.URL.Path
+ if len(path) > 512 {
+ path = path[:512]
+ }
+ return AccessLogLite{
+ CreatedAt: time.Now(),
+ Method: r.Method,
+ Path: path,
+ Status: status,
+ Bytes: bytes,
+ DurationMs: durationMs,
+ IP: ip,
+ UA: ua,
+ Referer: ref,
+ CDNCountry: cdnCountryFromRequest(r),
+ IsBot: IsSEOCrawler(ua) || isGenericBot(ua),
+ }
+}
+
+func isGenericBot(ua string) bool {
+ l := strings.ToLower(ua)
+ for _, t := range []string{"bot", "spider", "crawl", "slurp", "curl/", "wget/", "python-requests", "go-http-client"} {
+ if strings.Contains(l, t) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/service/monitor_test.go b/service/monitor_test.go
new file mode 100644
index 0000000..62dd737
--- /dev/null
+++ b/service/monitor_test.go
@@ -0,0 +1,85 @@
+package service
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestNormalizeCountryCode(t *testing.T) {
+ if normalizeCountryCode("cn") != "CN" {
+ t.Fatal("expected CN")
+ }
+ if normalizeCountryCode("XX") != "" {
+ t.Fatal("XX should be empty")
+ }
+ if normalizeCountryCode("USA") != "" {
+ t.Fatal("USA should be empty")
+ }
+}
+
+func TestShouldSkipDefaults(t *testing.T) {
+ rules := DefaultMonitorExcludeRules()
+ for _, path := range []string{
+ "/health", "/uploads/a.png", "/api/admin/monitor/overview",
+ "/assets/app.js", "/admin", "/admin/dashboard", "/api/me", "/api/site-branding",
+ } {
+ skip := false
+ lower := strings.ToLower(path)
+ for _, rule := range rules {
+ r := strings.ToLower(strings.TrimSpace(rule))
+ if r == "" {
+ continue
+ }
+ if strings.HasPrefix(r, ".") {
+ if strings.HasSuffix(lower, r) {
+ skip = true
+ break
+ }
+ continue
+ }
+ if strings.HasPrefix(lower, r) || lower == strings.TrimSuffix(r, "/") {
+ skip = true
+ break
+ }
+ }
+ if !skip {
+ t.Fatalf("expected skip for %s", path)
+ }
+ }
+}
+
+func TestNormalizePageViewPath(t *testing.T) {
+ cases := []struct {
+ in string
+ ok bool
+ want string
+ }{
+ {"/", true, "/"},
+ {"/post/1", true, "/post/1"},
+ {"/post/1?x=1", true, "/post/1?x=1"},
+ {"/admin", false, ""},
+ {"/admin/dashboard", false, ""},
+ {"/login", false, ""},
+ {"/api/posts", false, ""},
+ {"https://evil.com/", false, ""},
+ {"", false, ""},
+ }
+ for _, c := range cases {
+ got, ok := NormalizePageViewPath(c.in)
+ if ok != c.ok || got != c.want {
+ t.Fatalf("NormalizePageViewPath(%q)=(%q,%v) want (%q,%v)", c.in, got, ok, c.want, c.ok)
+ }
+ }
+}
+
+func TestClassifyUA(t *testing.T) {
+ if classifyUA("Mozilla/5.0 Chrome/120", "browser") != "Chrome" {
+ t.Fatal("browser")
+ }
+ if classifyUA("Mozilla/5.0 (Windows NT 10.0)", "os") != "Windows" {
+ t.Fatal("os")
+ }
+ if classifyUA("iPhone", "device") != "Mobile" {
+ t.Fatal("device")
+ }
+}
diff --git a/service/ratelimit.go b/service/ratelimit.go
index 1aa6408..677bdaa 100644
--- a/service/ratelimit.go
+++ b/service/ratelimit.go
@@ -57,6 +57,9 @@ func (r *RateLimiter) limitFor(action string) int {
if action == "community_heartbeat" {
return 30
}
+ if action == "monitor_pageview" {
+ return 120 // SPA 路由切换较频繁
+ }
return r.settings.RateLimitFor(action)
}
@@ -67,6 +70,9 @@ func (r *RateLimiter) windowFor(action string) time.Duration {
if action == "community_heartbeat" {
return time.Hour
}
+ if action == "monitor_pageview" {
+ return time.Minute
+ }
return time.Duration(r.settings.RateLimitWindowSec()) * time.Second
}
diff --git a/service/settings.go b/service/settings.go
index 962f26c..26f7f9d 100644
--- a/service/settings.go
+++ b/service/settings.go
@@ -3,6 +3,7 @@ package service
import (
"encoding/json"
"errors"
+ "net"
"net/url"
"strconv"
"strings"
@@ -42,9 +43,12 @@ const (
SettingAsideShowTagCloud = "aside_show_tag_cloud"
SettingAsideShowRecentComments = "aside_show_recent_comments"
SettingAsideShowFriendLinks = "aside_show_friend_links"
+ SettingAsideShowShowcase = "aside_show_showcase"
SettingAsideWidgets = "aside_widgets"
SettingNavShowFriendLinks = "nav_show_friend_links"
SettingFooterShowFriendLinks = "footer_show_friend_links"
+ SettingNavShowShowcase = "nav_show_showcase"
+ SettingFooterShowShowcase = "footer_show_showcase"
SettingFeedListStyle = "feed_list_style"
// 伪静态键名见 permalink.go:SettingPermalinkEnabled / SettingPermalinkExt
@@ -99,9 +103,18 @@ const (
SettingCommunityHubURL = "community_hub_url"
SettingCommunitySiteURL = "community_site_url" // 上报用的本站公开地址(可回退 OIDC ROOT_URL)
+ SettingMonitorEnabled = "monitor_enabled"
+ SettingMonitorRetention = "monitor_retention_days"
+ SettingMonitorAccessLogRetention = "monitor_access_log_retention_days"
+ SettingMonitorExclude = "monitor_exclude_json"
+ SettingMonitorTrustProxy = "monitor_trust_proxy"
+
// DefaultCommunityHubURL 官方演示站(社区枢纽默认地址)
DefaultCommunityHubURL = "https://bbs.iioio.com"
+ // DefaultMonitorExcludeJSON 默认排除路径/后缀(JSON 数组)
+ DefaultMonitorExcludeJSON = `["/admin","/api/admin/","/api/me","/api/site-branding","/health","/uploads/","/media/","/static/","/spa/","/api/admin/monitor","/api/monitor/pageview","/favicon.ico",".js",".css",".map",".woff",".woff2",".ttf",".png",".jpg",".jpeg",".gif",".webp",".avif",".svg",".ico"]`
+
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size),非后台可配项
pageSizeAPIMax = 100
)
@@ -138,9 +151,12 @@ type ForumLimits struct {
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
+ AsideShowShowcase bool `json:"aside_show_showcase"`
AsideWidgets []AsideWidget `json:"aside_widgets"`
NavShowFriendLinks bool `json:"nav_show_friend_links"`
FooterShowFriendLinks bool `json:"footer_show_friend_links"`
+ NavShowShowcase bool `json:"nav_show_showcase"`
+ FooterShowShowcase bool `json:"footer_show_showcase"`
FeedListStyle string `json:"feed_list_style"`
@@ -159,6 +175,7 @@ const (
AsideWidgetRecentComments = "recent_comments"
AsideWidgetRecentUsers = "recent_users"
AsideWidgetFriendLinks = "friend_links"
+ AsideWidgetShowcase = "showcase"
)
var asideWidgetDefaultOrder = []string{
@@ -166,6 +183,7 @@ var asideWidgetDefaultOrder = []string{
AsideWidgetRecentComments,
AsideWidgetRecentUsers,
AsideWidgetFriendLinks,
+ AsideWidgetShowcase,
}
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
@@ -189,14 +207,20 @@ type ForumLimitsPublic struct {
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
+ AsideShowShowcase bool `json:"aside_show_showcase"`
AsideWidgets []AsideWidget `json:"aside_widgets"`
NavShowFriendLinks bool `json:"nav_show_friend_links"`
FooterShowFriendLinks bool `json:"footer_show_friend_links"`
+ NavShowShowcase bool `json:"nav_show_showcase"`
+ FooterShowShowcase bool `json:"footer_show_showcase"`
FeedListStyle string `json:"feed_list_style"`
PermalinkEnabled bool `json:"permalink_enabled"`
PermalinkExt string `json:"permalink_ext"`
+
+ // MonitorPageview 是否接受前台路由 pageview 信标(与 monitor_enabled 同步)
+ MonitorPageview bool `json:"monitor_pageview"`
}
type settingDef struct {
@@ -243,7 +267,8 @@ var asideSettingDefaults = map[string]string{
SettingAsideShowTagCloud: "0",
SettingAsideShowRecentComments: "0",
SettingAsideShowFriendLinks: "1",
- SettingAsideWidgets: `[{"id":"tag_cloud","enabled":false},{"id":"recent_comments","enabled":false},{"id":"friend_links","enabled":true}]`,
+ SettingAsideShowShowcase: "0",
+ SettingAsideWidgets: `[{"id":"tag_cloud","enabled":false},{"id":"recent_comments","enabled":false},{"id":"friend_links","enabled":true},{"id":"showcase","enabled":false}]`,
}
var mailSettingDefaults = map[string]string{
@@ -289,6 +314,8 @@ var friendLinkSettingDefaults = map[string]string{
SettingFriendLinkReciprocalCheck: "0", // 默认关闭回链检测
SettingNavShowFriendLinks: "1",
SettingFooterShowFriendLinks: "1",
+ SettingNavShowShowcase: "0",
+ SettingFooterShowShowcase: "0",
}
var communitySettingDefaults = map[string]string{
@@ -299,6 +326,14 @@ var communitySettingDefaults = map[string]string{
SettingCommunitySiteURL: "",
}
+var monitorSettingDefaults = map[string]string{
+ SettingMonitorEnabled: "0",
+ SettingMonitorRetention: "30",
+ SettingMonitorAccessLogRetention: "7",
+ SettingMonitorExclude: DefaultMonitorExcludeJSON,
+ SettingMonitorTrustProxy: "1",
+}
+
var siteBrandingDefaults = map[string]string{
SettingSiteName: "姜十三论坛",
SettingSiteSlogan: "拾三一隅,自在交流",
@@ -392,15 +427,35 @@ type GiteaSyncConfig struct {
RepoCount int64 `json:"repo_count"`
}
-// CommunityConfig 社区上报配置(HubEnabled 只读,来自运维配置)
+// CommunityConfig 社区上报配置(HubEnabled 只读:运维开关或官网域名)
type CommunityConfig struct {
ReportEnabled bool `json:"report_enabled"`
- HubEnabled bool `json:"hub_enabled"` // 只读:app.ini / 环境变量
+ HubEnabled bool `json:"hub_enabled"` // 只读:app.ini / 环境变量 / 官网 Host
HubURL string `json:"hub_url"`
SiteURL string `json:"site_url"` // 上报用的本站公开地址
InstanceID string `json:"instance_id"`
}
+// MonitorConfig 网站监控采集设置
+type MonitorConfig struct {
+ Enabled bool `json:"enabled"`
+ RetentionDays int `json:"retention_days"` // page_views 保留
+ AccessLogRetentionDays int `json:"access_log_retention_days"` // JSONL 请求日志保留
+ ExcludeRules []string `json:"exclude_rules"`
+ DefaultExcludeRules []string `json:"default_exclude_rules"` // 只读:恢复推荐规则
+ TrustProxy bool `json:"trust_proxy"`
+ AccessLogDir string `json:"access_log_dir"`
+ IP2LocationV4Path string `json:"ip2location_v4_path"`
+ IP2LocationV6Path string `json:"ip2location_v6_path"`
+ IP2LocationV4Available bool `json:"ip2location_v4_available"`
+ IP2LocationV6Available bool `json:"ip2location_v6_available"`
+ GeoIPAvailable bool `json:"geoip_available"`
+ GeoIPCountryPath string `json:"geoip_country_path"`
+ GeoIPASNPath string `json:"geoip_asn_path"`
+ GeoIPCountryAvailable bool `json:"geoip_country_available"`
+ GeoIPASNAvailable bool `json:"geoip_asn_available"`
+}
+
// OIDCConfig OIDC Provider 全局配置(应用凭证见 oauth_clients)
type OIDCConfig struct {
Enabled bool `json:"enabled"`
@@ -505,6 +560,13 @@ func (s *ForumSettingsService) ensureDefaults() {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
}
}
+ for key, val := range monitorSettingDefaults {
+ var count int64
+ model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
+ if count == 0 {
+ model.DB.Create(&model.ForumSetting{Key: key, Value: val})
+ }
+ }
}
func (s *ForumSettingsService) getString(key, fallback string) string {
@@ -586,9 +648,12 @@ func (s *ForumSettingsService) Limits() ForumLimits {
AsideShowTagCloud: bools.tagCloud,
AsideShowRecentComments: bools.recentComments,
AsideShowFriendLinks: bools.friendLinks,
+ AsideShowShowcase: bools.showcase,
AsideWidgets: widgets,
NavShowFriendLinks: s.NavShowFriendLinks(),
FooterShowFriendLinks: s.FooterShowFriendLinks(),
+ NavShowShowcase: s.NavShowShowcase(),
+ FooterShowShowcase: s.FooterShowShowcase(),
FeedListStyle: s.FeedListStyle(),
@@ -619,14 +684,19 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
AsideShowTagCloud: limits.AsideShowTagCloud,
AsideShowRecentComments: limits.AsideShowRecentComments,
AsideShowFriendLinks: limits.AsideShowFriendLinks,
+ AsideShowShowcase: limits.AsideShowShowcase,
AsideWidgets: limits.AsideWidgets,
NavShowFriendLinks: limits.NavShowFriendLinks,
FooterShowFriendLinks: limits.FooterShowFriendLinks,
+ NavShowShowcase: limits.NavShowShowcase,
+ FooterShowShowcase: limits.FooterShowShowcase,
FeedListStyle: limits.FeedListStyle,
PermalinkEnabled: limits.PermalinkEnabled,
PermalinkExt: limits.PermalinkExt,
+
+ MonitorPageview: s.MonitorEnabled(),
}
}
@@ -669,8 +739,11 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
SettingAsideShowTagCloud: bools.tagCloud,
SettingAsideShowRecentComments: bools.recentComments,
SettingAsideShowFriendLinks: bools.friendLinks,
+ SettingAsideShowShowcase: bools.showcase,
SettingNavShowFriendLinks: in.NavShowFriendLinks,
SettingFooterShowFriendLinks: in.FooterShowFriendLinks,
+ SettingNavShowShowcase: in.NavShowShowcase,
+ SettingFooterShowShowcase: in.FooterShowShowcase,
SettingPermalinkEnabled: in.PermalinkEnabled,
}
for key, on := range boolUpdates {
@@ -770,6 +843,26 @@ func (s *ForumSettingsService) FooterShowFriendLinks() bool {
return s.getString(SettingFooterShowFriendLinks, "1") == "1"
}
+// NavShowShowcase 左侧栏「站点」是否展示开源展柜入口;缺省关闭
+func (s *ForumSettingsService) NavShowShowcase() bool {
+ return s.getString(SettingNavShowShowcase, "0") == "1"
+}
+
+// FooterShowShowcase 页脚是否展示开源展柜入口;缺省关闭
+func (s *ForumSettingsService) FooterShowShowcase() bool {
+ return s.getString(SettingFooterShowShowcase, "0") == "1"
+}
+
+// AsideShowShowcase 右侧栏是否展示开源展柜组件
+func (s *ForumSettingsService) AsideShowShowcase() bool {
+ for _, w := range s.AsideWidgets() {
+ if w.ID == AsideWidgetShowcase {
+ return w.Enabled
+ }
+ }
+ return s.getString(SettingAsideShowShowcase, "0") == "1"
+}
+
func (s *ForumSettingsService) SetFriendLinkReciprocalCheckEnabled(enabled bool) error {
v := "0"
if enabled {
@@ -794,19 +887,44 @@ func (s *ForumSettingsService) SetFooterShowFriendLinks(enabled bool) error {
return s.setString(SettingFooterShowFriendLinks, v)
}
+func (s *ForumSettingsService) SetNavShowShowcase(enabled bool) error {
+ v := "0"
+ if enabled {
+ v = "1"
+ }
+ return s.setString(SettingNavShowShowcase, v)
+}
+
+func (s *ForumSettingsService) SetFooterShowShowcase(enabled bool) error {
+ v := "0"
+ if enabled {
+ v = "1"
+ }
+ return s.setString(SettingFooterShowShowcase, v)
+}
+
// SetAsideFriendLinksEnabled 更新右侧栏友链组件开关(与 aside_widgets 同步)
func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error {
+ return s.setAsideWidgetEnabled(AsideWidgetFriendLinks, SettingAsideShowFriendLinks, enabled)
+}
+
+// SetAsideShowcaseEnabled 更新右侧栏开源展柜组件开关(与 aside_widgets 同步)
+func (s *ForumSettingsService) SetAsideShowcaseEnabled(enabled bool) error {
+ return s.setAsideWidgetEnabled(AsideWidgetShowcase, SettingAsideShowShowcase, enabled)
+}
+
+func (s *ForumSettingsService) setAsideWidgetEnabled(widgetID, boolSettingKey string, enabled bool) error {
widgets := s.AsideWidgets()
found := false
for i := range widgets {
- if widgets[i].ID == AsideWidgetFriendLinks {
+ if widgets[i].ID == widgetID {
widgets[i].Enabled = enabled
found = true
break
}
}
if !found {
- widgets = append(widgets, AsideWidget{ID: AsideWidgetFriendLinks, Enabled: enabled})
+ widgets = append(widgets, AsideWidget{ID: widgetID, Enabled: enabled})
}
widgets = NormalizeAsideWidgets(widgets)
bools := asideBoolsFromWidgets(widgets)
@@ -818,16 +936,28 @@ func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error {
return err
}
v := "0"
- if bools.friendLinks {
- v = "1"
+ switch widgetID {
+ case AsideWidgetFriendLinks:
+ if bools.friendLinks {
+ v = "1"
+ }
+ case AsideWidgetShowcase:
+ if bools.showcase {
+ v = "1"
+ }
+ default:
+ if enabled {
+ v = "1"
+ }
}
- return s.setString(SettingAsideShowFriendLinks, v)
+ return s.setString(boolSettingKey, v)
}
type asideWidgetBools struct {
tagCloud bool
recentComments bool
friendLinks bool
+ showcase bool
}
func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWidget {
@@ -835,6 +965,7 @@ func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWi
{ID: AsideWidgetTagCloud, Enabled: tagCloud},
{ID: AsideWidgetRecentComments, Enabled: recentComments},
{ID: AsideWidgetFriendLinks, Enabled: friendLinks},
+ {ID: AsideWidgetShowcase, Enabled: false},
}
}
@@ -848,6 +979,8 @@ func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
out.recentComments = w.Enabled
case AsideWidgetFriendLinks:
out.friendLinks = w.Enabled
+ case AsideWidgetShowcase:
+ out.showcase = w.Enabled
}
}
return out
@@ -855,7 +988,7 @@ func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
func isValidAsideWidgetID(id string) bool {
switch id {
- case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetFriendLinks:
+ case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetFriendLinks, AsideWidgetShowcase:
return true
default:
return false
@@ -1131,20 +1264,75 @@ func (s *ForumSettingsService) UpdateGiteaSyncConfig(in GiteaSyncConfig) error {
return nil
}
-// CommunityConfig 读取社区上报配置
+// CommunityConfig 读取社区上报配置(无请求上下文;官网需靠已存 ROOT_URL / site_url)
func (s *ForumSettingsService) CommunityConfig() CommunityConfig {
- s.mu.RLock()
- hubEnabled := s.communityHubEnabled
- s.mu.RUnlock()
+ return s.CommunityConfigForRequest("")
+}
+
+// CommunityConfigForRequest 读取社区配置;requestHint 可为 Origin / 公开 URL,用于识别官网 Host
+func (s *ForumSettingsService) CommunityConfigForRequest(requestHint string) CommunityConfig {
return CommunityConfig{
ReportEnabled: s.getString(SettingCommunityReportEnabled, "0") == "1",
- HubEnabled: hubEnabled,
+ HubEnabled: s.CommunityHubEnabled(requestHint),
HubURL: DefaultCommunityHubURL,
- SiteURL: s.CommunitySiteURL(""),
+ SiteURL: s.CommunitySiteURL(requestHint),
InstanceID: strings.TrimSpace(s.getString(SettingCommunityInstanceID, "")),
}
}
+// CommunityHubEnabled 是否作为社区枢纽:运维开关,或本站即为官方演示站域名
+func (s *ForumSettingsService) CommunityHubEnabled(requestHint string) bool {
+ s.mu.RLock()
+ ops := s.communityHubEnabled
+ s.mu.RUnlock()
+ if ops {
+ return true
+ }
+ official := officialCommunityHubHost()
+ if official == "" {
+ return false
+ }
+ for _, cand := range []string{
+ s.getString(SettingOIDCRootURL, ""),
+ s.getString(SettingCommunitySiteURL, ""),
+ requestHint,
+ } {
+ if hostFromURLOrHost(cand) == official {
+ return true
+ }
+ }
+ return false
+}
+
+// officialCommunityHubHost 官方枢纽规范化主机名(如 bbs.iioio.com)
+func officialCommunityHubHost() string {
+ return hostFromURLOrHost(DefaultCommunityHubURL)
+}
+
+// hostFromURLOrHost 从 URL 或裸 Host 提取规范化主机名(小写、去 www、去端口)
+func hostFromURLOrHost(raw string) string {
+ raw = strings.TrimSpace(strings.ToLower(raw))
+ if raw == "" {
+ return ""
+ }
+ if strings.Contains(raw, "://") {
+ u, err := url.Parse(raw)
+ if err != nil || u.Host == "" {
+ return ""
+ }
+ raw = u.Host
+ } else if i := strings.IndexAny(raw, "/?"); i >= 0 {
+ raw = raw[:i]
+ }
+ if raw == "" {
+ return ""
+ }
+ if h, _, err := net.SplitHostPort(raw); err == nil {
+ raw = h
+ }
+ return strings.TrimPrefix(raw, "www.")
+}
+
// CommunitySiteURL 上报用的本站公开地址:已持久化 > OIDC ROOT_URL > 请求 Origin
func (s *ForumSettingsService) CommunitySiteURL(requestOrigin string) string {
if u := normalizeRootURL(s.getString(SettingCommunitySiteURL, "")); u != "" {
@@ -1199,6 +1387,104 @@ func (s *ForumSettingsService) UpdateCommunityConfig(in CommunityConfig) (wasRep
return wasReportEnabled, nil
}
+// DefaultMonitorExcludeRules 导出推荐排除规则(供设置页「恢复默认」)
+func DefaultMonitorExcludeRules() []string {
+ return parseMonitorExcludeJSON(DefaultMonitorExcludeJSON)
+}
+
+func normalizeMonitorRetentionDays(v int) int {
+ if v < 1 {
+ return 1
+ }
+ if v > 365 {
+ return 365
+ }
+ return v
+}
+
+// MonitorConfig 读取网站监控设置
+func (s *ForumSettingsService) MonitorConfig() MonitorConfig {
+ retention, _ := strconv.Atoi(s.getString(SettingMonitorRetention, "30"))
+ accessRetention, _ := strconv.Atoi(s.getString(SettingMonitorAccessLogRetention, "7"))
+ return MonitorConfig{
+ Enabled: s.getString(SettingMonitorEnabled, "0") == "1",
+ RetentionDays: normalizeMonitorRetentionDays(retention),
+ AccessLogRetentionDays: normalizeMonitorRetentionDays(accessRetention),
+ ExcludeRules: parseMonitorExcludeJSON(s.getString(SettingMonitorExclude, DefaultMonitorExcludeJSON)),
+ DefaultExcludeRules: DefaultMonitorExcludeRules(),
+ TrustProxy: s.getString(SettingMonitorTrustProxy, "1") == "1",
+ }
+}
+
+// MonitorEnabled 采集是否开启
+func (s *ForumSettingsService) MonitorEnabled() bool {
+ return s.getString(SettingMonitorEnabled, "0") == "1"
+}
+
+// UpdateMonitorConfig 更新网站监控设置
+func (s *ForumSettingsService) UpdateMonitorConfig(in MonitorConfig) error {
+ enabled := "0"
+ if in.Enabled {
+ enabled = "1"
+ }
+ retention := normalizeMonitorRetentionDays(in.RetentionDays)
+ accessRetention := normalizeMonitorRetentionDays(in.AccessLogRetentionDays)
+ trust := "0"
+ if in.TrustProxy {
+ trust = "1"
+ }
+ rules := in.ExcludeRules
+ if rules == nil {
+ rules = parseMonitorExcludeJSON(DefaultMonitorExcludeJSON)
+ }
+ raw, err := json.Marshal(normalizeMonitorExclude(rules))
+ if err != nil {
+ return err
+ }
+ updates := map[string]string{
+ SettingMonitorEnabled: enabled,
+ SettingMonitorRetention: strconv.Itoa(retention),
+ SettingMonitorAccessLogRetention: strconv.Itoa(accessRetention),
+ SettingMonitorExclude: string(raw),
+ SettingMonitorTrustProxy: trust,
+ }
+ for key, val := range updates {
+ if err := s.setString(key, val); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func parseMonitorExcludeJSON(raw string) []string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ raw = DefaultMonitorExcludeJSON
+ }
+ var rules []string
+ if err := json.Unmarshal([]byte(raw), &rules); err != nil {
+ _ = json.Unmarshal([]byte(DefaultMonitorExcludeJSON), &rules)
+ }
+ return normalizeMonitorExclude(rules)
+}
+
+func normalizeMonitorExclude(rules []string) []string {
+ seen := make(map[string]struct{}, len(rules))
+ out := make([]string, 0, len(rules))
+ for _, r := range rules {
+ r = strings.TrimSpace(r)
+ if r == "" {
+ continue
+ }
+ if _, ok := seen[r]; ok {
+ continue
+ }
+ seen[r] = struct{}{}
+ out = append(out, r)
+ }
+ return out
+}
+
// StorageConfig 读取上传存储配置(含密钥明文,供内部使用)
func (s *ForumSettingsService) StorageConfig() StorageConfig {
secret := s.getString(SettingStorageSecretKey, "")
|