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 (
暂无来源数据
+暂无浏览量来源数据
{emptyHint}
- 浏览量/访客来自前台路由 pageview;请求数与日志来自服务端访问采集(含 /api) + 浏览量/访客与访客地图来自前台路由 pageview;请求数、流量与请求日志来自服务端访问采集(含 /api,重启后请求类今日指标重计)
@@ -391,8 +403,11 @@ export default function AdminMonitorPage() {+ 基于前台浏览 · 近 30 日 + {mapMode === 'china' ? ' · 按省/区填色' : ' · 按国家填色'} +
+