package handler import ( "net" "strings" "github.com/gin-gonic/gin" "kefu-cloud/server/internal/geoip" "kefu-cloud/server/internal/service" ) // resolveVisitorIP 优先信任反代头,回落到 Gin ClientIP。 func resolveVisitorIP(c *gin.Context) string { candidates := []string{ c.GetHeader("CF-Connecting-IP"), c.GetHeader("True-Client-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 } } } // Nginx 经 Caddy/网关转发时,X-Real-IP 通常是上一跳代理地址; // X-Forwarded-For 才包含访客地址,因此放在 XFF 之后作为回退。 candidates = append(candidates, c.GetHeader("X-Real-IP")) 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 } type visitorLocation struct { Country string Province string City string Region string } // resolveVisitorLocation 优先使用离线 GeoIP,未配置时降级到 CDN 国家头。 func resolveVisitorLocation(c *gin.Context, ip string) visitorLocation { parsed := net.ParseIP(ip) if parsed != nil && (parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast() || parsed.IsLinkLocalMulticast()) { return visitorLocation{Region: "内网"} } if location, ok := geoip.Lookup(ip); ok { return visitorLocation{ Country: location.Country, Province: location.Province, City: location.City, Region: location.Region, } } // 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 visitorLocation{Country: name, Region: name} } return visitorLocation{Country: country, Region: country} } return visitorLocation{Region: "未知"} } func resolveVisitorRegion(c *gin.Context, ip string) string { return resolveVisitorLocation(c, ip).Region } 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) service.VisitorMeta { ip := resolveVisitorIP(c) location := resolveVisitorLocation(c, ip) clip := func(value string, max int) string { value = strings.TrimSpace(value) runes := []rune(value) if len(runes) > max { return string(runes[:max]) } return value } ua := c.Request.UserAgent() if len(ua) > 500 { ua = ua[:500] } return service.VisitorMeta{ IP: ip, Region: clip(location.Region, 100), Country: clip(location.Country, 100), Province: clip(location.Province, 100), City: clip(location.City, 100), UserAgent: ua, DeviceLabel: summarizeUserAgent(ua), } }