162 lines
4.7 KiB
Go
162 lines
4.7 KiB
Go
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),
|
|
}
|
|
}
|