package handler import ( "net" "strings" "github.com/gin-gonic/gin" ) // resolveVisitorIP 优先信任反代头,回落到 Gin ClientIP。 func resolveVisitorIP(c *gin.Context) string { candidates := []string{ c.GetHeader("CF-Connecting-IP"), c.GetHeader("True-Client-IP"), c.GetHeader("X-Real-IP"), } if xff := c.GetHeader("X-Forwarded-For"); xff != "" { // 取最左侧第一个非空 IP for _, part := range strings.Split(xff, ",") { part = strings.TrimSpace(part) if part != "" { candidates = append(candidates, part) break } } } for _, raw := range candidates { ip := strings.TrimSpace(raw) if ip == "" { continue } // 去掉端口 if host, _, err := net.SplitHostPort(ip); err == nil { ip = host } if net.ParseIP(ip) != nil { return ip } } ip := c.ClientIP() if host, _, err := net.SplitHostPort(ip); err == nil { return host } return ip } // resolveVisitorRegion 基于 IP 与常见 CDN 国家头做粗粒度地区,无需第三方库。 // 私网/本机显示「内网」;有国家头则映射中文名;否则「未知」。 func resolveVisitorRegion(c *gin.Context, ip string) string { // Cloudflare / 部分网关国家码 country := strings.ToUpper(strings.TrimSpace(c.GetHeader("CF-IPCountry"))) if country == "" { country = strings.ToUpper(strings.TrimSpace(c.GetHeader("X-AppEngine-Country"))) } if country == "" { country = strings.ToUpper(strings.TrimSpace(c.GetHeader("CloudFront-Viewer-Country"))) } if country != "" && country != "XX" && country != "T1" { if name, ok := countryNameCN(country); ok { return name } return country } parsed := net.ParseIP(ip) if parsed == nil { return "未知" } if parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast() || parsed.IsLinkLocalMulticast() { return "内网" } // 未接入 GeoIP 库时,公网 IP 地区暂标未知(可后续接入离线库) return "未知" } func countryNameCN(code string) (string, bool) { names := map[string]string{ "CN": "中国", "HK": "中国香港", "MO": "中国澳门", "TW": "中国台湾", "US": "美国", "JP": "日本", "KR": "韩国", "SG": "新加坡", "GB": "英国", "DE": "德国", "FR": "法国", "AU": "澳大利亚", "CA": "加拿大", "RU": "俄罗斯", "IN": "印度", "BR": "巴西", "MY": "马来西亚", "TH": "泰国", "VN": "越南", "PH": "菲律宾", "ID": "印度尼西亚", "AE": "阿联酋", "SA": "沙特阿拉伯", } name, ok := names[code] return name, ok } // summarizeUserAgent 将 UA 压缩为可读设备摘要,如 "Chrome / macOS"。 func summarizeUserAgent(ua string) string { ua = strings.TrimSpace(ua) if ua == "" { return "" } browser := "浏览器" osName := "未知系统" switch { case strings.Contains(ua, "Edg/"): browser = "Edge" case strings.Contains(ua, "Chrome/") && !strings.Contains(ua, "Edg/"): browser = "Chrome" case strings.Contains(ua, "Firefox/"): browser = "Firefox" case strings.Contains(ua, "Safari/") && !strings.Contains(ua, "Chrome/"): browser = "Safari" case strings.Contains(ua, "MicroMessenger"): browser = "微信" } switch { case strings.Contains(ua, "Windows"): osName = "Windows" case strings.Contains(ua, "Mac OS X") || strings.Contains(ua, "Macintosh"): osName = "macOS" case strings.Contains(ua, "Android"): osName = "Android" case strings.Contains(ua, "iPhone") || strings.Contains(ua, "iPad"): osName = "iOS" case strings.Contains(ua, "Linux"): osName = "Linux" } return browser + " / " + osName } func captureVisitorMeta(c *gin.Context) (ip, region, ua, device string) { ip = resolveVisitorIP(c) region = resolveVisitorRegion(c, ip) ua = c.Request.UserAgent() if len(ua) > 500 { ua = ua[:500] } device = summarizeUserAgent(ua) return }