package service import ( "net/netip" "strings" ) // DeviceKind 设备形态(前端据此选图标) const ( DeviceKindComputer = "computer" DeviceKindMobile = "mobile" DeviceKindTablet = "tablet" ) // ParsedUA 从 User-Agent 抽出的可读设备信息 type ParsedUA struct { Kind string // computer | mobile | tablet OS string // Windows / macOS / Android / iOS / Linux Label string // Windows 电脑 / Android 手机 Browser string } // ParseUserAgent 轻量 UA 解析,覆盖常见桌面/移动浏览器,不引入第三方库。 func ParseUserAgent(ua string) ParsedUA { u := strings.TrimSpace(ua) if u == "" { return ParsedUA{Kind: DeviceKindComputer, OS: "未知", Label: "未知设备", Browser: "未知浏览器"} } low := strings.ToLower(u) kind, osName, label := classifyDevice(u, low) browser := classifyBrowser(u, low) return ParsedUA{Kind: kind, OS: osName, Label: label, Browser: browser} } func classifyDevice(u, low string) (kind, osName, label string) { hasAndroid := strings.Contains(low, "android") hasIPhone := strings.Contains(low, "iphone") hasIPad := strings.Contains(low, "ipad") || (strings.Contains(low, "macintosh") && strings.Contains(low, "mobile")) hasMobileToken := strings.Contains(low, "mobile") || strings.Contains(u, "Mobile") switch { case hasIPad: return DeviceKindTablet, "iOS", "iPad" case hasIPhone: return DeviceKindMobile, "iOS", "iOS 手机" case hasAndroid: if strings.Contains(low, "tablet") || !hasMobileToken { return DeviceKindTablet, "Android", "Android 平板" } return DeviceKindMobile, "Android", "Android 手机" case strings.Contains(low, "windows"): return DeviceKindComputer, "Windows", "Windows 电脑" case strings.Contains(low, "mac os") || strings.Contains(low, "macintosh"): return DeviceKindComputer, "macOS", "macOS 电脑" case strings.Contains(low, "cros"): return DeviceKindComputer, "ChromeOS", "ChromeOS 电脑" case strings.Contains(low, "linux"): return DeviceKindComputer, "Linux", "Linux 电脑" default: if hasMobileToken { return DeviceKindMobile, "未知", "手机" } return DeviceKindComputer, "未知", "电脑" } } func classifyBrowser(u, low string) string { switch { case strings.Contains(u, "MicroMessenger"): return "微信" case strings.Contains(low, "edg/") || strings.Contains(low, "edgios/") || strings.Contains(low, "edga/"): return "Microsoft Edge" case strings.Contains(low, "opr/") || strings.Contains(low, "opera"): return "Opera" case strings.Contains(low, "firefox/") || strings.Contains(low, "fxios/"): return "Firefox" case strings.Contains(low, "crios/"): return "Google Chrome" case strings.Contains(low, "chrome/") && !strings.Contains(low, "edg"): return "Google Chrome" case strings.Contains(low, "safari/") && !strings.Contains(low, "chrome") && !strings.Contains(low, "chromium"): return "Safari" default: return "未知浏览器" } } func uaFingerprint(p ParsedUA) string { return p.Kind + "|" + p.OS + "|" + p.Browser } func parseIPAddr(ip string) (netip.Addr, bool) { ip = strings.TrimSpace(ip) if ip == "" { return netip.Addr{}, false } if i := strings.IndexByte(ip, '%'); i >= 0 { ip = ip[:i] } addr, err := netip.ParseAddr(ip) if err != nil { return netip.Addr{}, false } return addr.Unmap(), true } // canonicalIP 归一化 IP:IPv4-mapped 解开;本机 ::1 / 127.0.0.1 视为同一地址。 func canonicalIP(ip string) string { addr, ok := parseIPAddr(ip) if !ok { return strings.TrimSpace(ip) } if addr.IsLoopback() { return "127.0.0.1" } return addr.String() } func deviceFingerprint(ip, ua string) string { return canonicalIP(ip) + "|" + uaFingerprint(ParseUserAgent(ua)) }