diff --git a/cmd/monitor-geo-check/main.go b/cmd/monitor-geo-check/main.go index 2527000..ba04864 100644 --- a/cmd/monitor-geo-check/main.go +++ b/cmd/monitor-geo-check/main.go @@ -171,6 +171,10 @@ func main() { } fmt.Println() + fmt.Println("=== 对账抽样(近 30 日非 bot)===") + printGeoReconcileSamples(db, have, since) + fmt.Println() + fmt.Println("=== 样例行(近 30 日,最多 10 条)===") cols := []string{"id", "created_at", "path", "is_bot"} for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} { @@ -217,10 +221,103 @@ func main() { } fmt.Fprintln(os.Stderr, "请确认数据目录已放置 IP2LOCATION-LITE-DB3.BIN 与 GeoLite2-ASN.mmdb,且有公网访问产生的 pageview。") fmt.Fprintln(os.Stderr, "本机/私网 IP 通常解不出省,属正常。") + fmt.Fprintln(os.Stderr, "注意: 请求日志有地理 ≠ 概览地图/排行有数据(后者只读 page_views)。") os.Exit(2) } } +func printGeoReconcileSamples(db *gorm.DB, have map[string]bool, since time.Time) { + type sample struct { + ID uint + CreatedAt time.Time + IP string + Country string + Region string + RegionISO string `gorm:"column:region_iso"` + City string + ASN uint + ASOrg string `gorm:"column:as_org"` + Path string + } + selCols := []string{"id", "created_at", "path"} + for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} { + if have[c] { + selCols = append(selCols, c) + } + } + sel := strings.Join(selCols, ", ") + + printBlock := func(title string, n int64, rows []sample) { + fmt.Printf("%s: %d\n", title, n) + if len(rows) == 0 { + return + } + for _, s := range rows { + fmt.Printf(" #%d %s ip=%s country=%q region=%q iso=%q city=%q asn=%d org=%q path=%s\n", + s.ID, s.CreatedAt.Format("2006-01-02 15:04:05"), s.IP, + s.Country, s.Region, s.RegionISO, s.City, s.ASN, s.ASOrg, s.Path) + } + } + + if have["city"] { + var n int64 + _ = db.Table("page_views"). + Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false). + Count(&n).Error + var rows []sample + _ = db.Table("page_views"). + Select(sel). + Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false). + Order("id DESC"). + Limit(5). + Scan(&rows).Error + printBlock("英文城市名(可能未中文化)", n, rows) + } else { + fmt.Println("英文城市名: (city 列缺失)") + } + + if have["region"] || have["region_iso"] { + cond := "created_at >= ? AND is_bot = ? AND country <> ''" + emptyParts := []string{} + if have["region"] { + emptyParts = append(emptyParts, "(region = '' OR region IS NULL OR region = '-')") + } + if have["region_iso"] { + emptyParts = append(emptyParts, "(region_iso = '' OR region_iso IS NULL)") + } + where := cond + " AND (" + strings.Join(emptyParts, " AND ") + ")" + var n int64 + _ = db.Table("page_views").Where(where, since, false).Count(&n).Error + var rows []sample + _ = db.Table("page_views"). + Select(sel). + Where(where, since, false). + Order("id DESC"). + Limit(5). + Scan(&rows).Error + printBlock("有国家但省为空", n, rows) + } else { + fmt.Println("有国家但省为空: (省列缺失)") + } + + if have["asn"] { + var n int64 + _ = db.Table("page_views"). + Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false). + Count(&n).Error + var rows []sample + _ = db.Table("page_views"). + Select(sel). + Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false). + Order("id DESC"). + Limit(5). + Scan(&rows).Error + printBlock("asn=0(无运营商)", n, rows) + } else { + fmt.Println("asn=0: (asn 列缺失)") + } +} + func printGeoFiles(dataDir string, suite *service.GeoIPService) { fmt.Println("=== Geo 数据文件 ===") v4, v6, asn, country := suite.Paths() diff --git a/docs/monitor.md b/docs/monitor.md index 14de7db..c275873 100644 --- a/docs/monitor.md +++ b/docs/monitor.md @@ -37,6 +37,8 @@ CDN 头(如 `CF-IPCountry`)仅在本地库无国家码时补全。不落 Lat 写入时做本地中文映射(省 / 中国城市按 IP2Location 英文名 / 运营商);同音城市(如苏州/宿州)按省份歧义。`country` 存 ISO2,展示用中文名。历史 `page_views` / 请求日志不会回写,仅影响新写入。 +**口径提示:** 访客地图与城市/运营商排行只聚合 `page_views`(前台浏览);请求日志里的地理来自 access JSONL。仅有请求日志、没有前台浏览时,地图与排行可为空——属预期。 + ## 写入路径(性能) 1. 中间件:监控关闭或命中排除规则则直接放行;否则 `c.Next()` 后**仅入队**轻量字段(不查 BIN/ASN、不写盘)。 @@ -70,6 +72,7 @@ CDN 头(如 `CF-IPCountry`)仅在本地库无国家码时补全。不落 Lat go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246 ``` +会输出 Geo 文件状态、近 30 日省级 Top,以及对账抽样(英文城市名、有国家但省为空、`asn=0`)。 ## 明确不做 - 不用 CIDR;不把请求日志写入 SQLite;不把 page_views 写入主库 diff --git a/frontend/src/components/admin/MonitorChinaMap.tsx b/frontend/src/components/admin/MonitorChinaMap.tsx index 5628036..f92d72a 100644 --- a/frontend/src/components/admin/MonitorChinaMap.tsx +++ b/frontend/src/components/admin/MonitorChinaMap.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react'; import china from '@svg-maps/china'; import { cn } from '@/lib/utils'; -import { buildChinaRegionCountMap, heatFill, emptyFill } from './monitorMapUtils'; +import { buildChinaRegionCountMap, heatFill } from './monitorMapUtils'; type Loc = { id: string; name: string; path: string }; @@ -15,10 +15,19 @@ type RegionStat = { type Props = { regions: RegionStat[]; className?: string; + /** 近 30 日是否已有国家级浏览量 */ + hasCountryHits?: boolean; + /** 近 30 日是否已有省级浏览量 */ + hasRegionHits?: boolean; }; /** 中国省区地图:按 BIN 解析出的省/区填色 */ -export default function MonitorChinaMap({ regions, className }: Props) { +export default function MonitorChinaMap({ + regions, + className, + hasCountryHits = false, + hasRegionHits = false, +}: Props) { const [hover, setHover] = useState<{ name: string; count: number } | null>(null); const counts = useMemo(() => buildChinaRegionCountMap(regions), [regions]); const max = useMemo(() => Math.max(0, ...Object.values(counts)), [counts]); @@ -26,6 +35,16 @@ export default function MonitorChinaMap({ regions, className }: Props) { const locations = (china as { locations: Loc[]; viewBox: string }).locations; const viewBox = (china as { viewBox: string }).viewBox; + let emptyTitle = '暂无省级访问数据'; + let emptyHint = '放置 IP2LOCATION-LITE-DB3.BIN 后按省/区填色'; + if (hasCountryHits && !hasRegionHits) { + emptyTitle = '有国家浏览量,尚无省级数据'; + emptyHint = '需前台路由产生带省字段的浏览量;仅请求日志不会点亮中国地图。请确认已放置 IP2Location DB3 BIN。'; + } else if (!hasCountryHits) { + emptyTitle = '暂无浏览量地理数据'; + emptyHint = '需前台路由产生浏览量后才会按省填色;仅请求日志不会点亮此处。'; + } + return (
-

暂无省级访问数据

-

放置 IP2LOCATION-LITE-DB3.BIN 后按省/区填色

+

{emptyTitle}

+

{emptyHint}

)} diff --git a/frontend/src/pages/admin/AdminMonitorPage.tsx b/frontend/src/pages/admin/AdminMonitorPage.tsx index 28406e4..9d6545b 100644 --- a/frontend/src/pages/admin/AdminMonitorPage.tsx +++ b/frontend/src/pages/admin/AdminMonitorPage.tsx @@ -79,23 +79,29 @@ function GeoRankTable({ cities, asns, mode, - mapMode, rankMode, onRankMode, + hasCountryHits, }: { cities: MonitorCityItem[]; asns: MonitorASNItem[]; mode: MapMode; rankMode: RankMode; onRankMode: (m: RankMode) => void; + /** 近 30 日是否已有国家级浏览量(用于区分「无 PV 地理」与缺库) */ + hasCountryHits: boolean; }) { 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 后可见运营商排行'; + ? (hasCountryHits + ? '需前台路由产生带城市的浏览量;仅请求日志不会进入此排行。请确认已放置 IP2Location DB3 BIN。' + : '需前台路由产生浏览量后才会有城市排行;仅请求日志不会点亮此处。') + : (hasCountryHits + ? '需浏览量写入 ASN;请确认已放置 GeoLite2-ASN.mmdb。仅请求日志不会进入此排行。' + : '需前台路由产生浏览量后才会有运营商排行;仅请求日志不会点亮此处。'); return (
@@ -117,7 +123,7 @@ function GeoRankTable({
{rows.length === 0 ? (
-

暂无来源数据

+

暂无浏览量来源数据

{emptyHint}

) : ( @@ -282,18 +288,24 @@ export default function AdminMonitorPage() { const metrics = useMemo(() => { if (!overview) return []; + const pvHint = '前台浏览'; + const accessHintShort = '服务端请求'; + const accessHintFull = '服务端请求(含 API,重启后重计)'; 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) }, + { label: '浏览量', hint: pvHint, title: pvHint, value: formatNum(overview.pageviews) }, + { label: '访客数', hint: pvHint, title: pvHint, value: formatNum(overview.visitors) }, + { label: '独立 IP', hint: accessHintShort, title: accessHintFull, value: formatNum(overview.unique_ips) }, + { label: '流量', hint: accessHintShort, title: accessHintFull, value: formatBytes(overview.traffic) }, + { label: '蜘蛛', hint: accessHintShort, title: accessHintFull, value: formatNum(overview.bots) }, + { label: '请求数', hint: accessHintShort, title: accessHintFull, value: formatNum(overview.requests) }, + { label: '4xx', hint: accessHintShort, title: accessHintFull, value: formatNum(overview.status_4xx) }, + { label: '5xx', hint: accessHintShort, title: accessHintFull, value: formatNum(overview.status_5xx) }, ]; }, [overview]); + const hasCountryHits = (geo?.countries?.length || 0) > 0; + const hasRegionHits = (geo?.regions?.length || 0) > 0; + const saveSettings = async () => { if (!settings) return; setSaving(true); @@ -352,7 +364,7 @@ export default function AdminMonitorPage() { 网站监控

- 浏览量/访客来自前台路由 pageview;请求数与日志来自服务端访问采集(含 /api) + 浏览量/访客与访客地图来自前台路由 pageview;请求数、流量与请求日志来自服务端访问采集(含 /api,重启后请求类今日指标重计)

@@ -391,8 +403,11 @@ export default function AdminMonitorPage() {
{metrics.map((m) => ( -
-
{m.label}
+
+
+ {m.label} + {m.hint} +
{m.value}
))} @@ -404,7 +419,13 @@ export default function AdminMonitorPage() {
-

访客地图(30 日)

+
+

访客地图

+

+ 基于前台浏览 · 近 30 日 + {mapMode === 'china' ? ' · 按省/区填色' : ' · 按国家填色'} +

+
@@ -438,6 +463,7 @@ export default function AdminMonitorPage() { mode={mapMode} rankMode={rankMode} onRankMode={setRankMode} + hasCountryHits={hasCountryHits} />
@@ -554,10 +580,10 @@ export default function AdminMonitorPage() { {logs.map((row) => { + const placeBits = [row.region, row.city, row.as_org].filter(Boolean); const geoBits = [ row.country ? countryLabel(row.country) : '', - row.city || '', - row.as_org || '', + ...placeBits, ].filter(Boolean); return ( @@ -568,9 +594,9 @@ export default function AdminMonitorPage() { {row.ip || '—'} {row.is_bot ? ' · bot' : ''} - {(row.city || row.as_org) ? ( + {placeBits.length > 0 ? (
- {[row.city, row.as_org].filter(Boolean).join(' · ')} + {placeBits.join(' · ')}
) : null} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index f6b4688..33b6cb0 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -15356,6 +15356,18 @@ button.post-poll__option, font-size: 12px; color: hsl(var(--muted-foreground)); margin-bottom: 6px; + display: flex; + flex-direction: column; + gap: 2px; +} +.admin-monitor-today-hint { + font-size: 10px; + font-weight: 400; + line-height: 1.3; + opacity: 0.85; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .admin-monitor-today-value { font-size: 22px; @@ -15365,6 +15377,13 @@ button.post-poll__option, line-height: 1.2; word-break: break-all; } +.admin-monitor-map-sub { + margin: 2px 0 0; + font-size: 12px; + font-weight: 400; + color: hsl(var(--muted-foreground)); + line-height: 1.4; +} .admin-monitor-main { display: grid;