package useragent import ( "fmt" "net/http" "net/url" "regexp" "strings" "github.com/gin-gonic/gin" ) const DefaultVer = "1.0.0" // Info 与前端 X-User-Agent 字段对齐,供 SSR 组装上游请求头。 type Info struct { RawUA string DevType string SysType string Ver string BuildID string DeviceBrand string DeviceModel string SystemName string SystemVersion string SID string } // BuildOptions SSR / 代理侧可选覆盖项。 type BuildOptions struct { Ver string SID string // RawUA 为空时从 Request.UserAgent 读取 RawUA string } var ( reIOSVer = regexp.MustCompile(`OS (\d+[_\d]*) like Mac OS X`) reAndroidVer = regexp.MustCompile(`Android ([\d.]+)`) reAndroidModel = regexp.MustCompile(`Android\s+[\d.]+;\s*([^;)]+?)(?:\s+Build\/|\))`) reWinNT = regexp.MustCompile(`Windows NT ([\d.]+)`) reMacVer = regexp.MustCompile(`Mac OS X ([\d_]+)`) reEdge = regexp.MustCompile(`(?i)Edg/([\d.]+)`) reOpera = regexp.MustCompile(`(?i)(?:OPR|Opera)/([\d.]+)`) reChrome = regexp.MustCompile(`(?i)Chrome/([\d.]+)`) reSafari = regexp.MustCompile(`(?i)Version/([\d.]+).*Safari`) reFirefox = regexp.MustCompile(`(?i)Firefox/([\d.]+)`) reIE = regexp.MustCompile(`(?i)(?:MSIE\s|Trident.*rv:)([\d.]+)`) ) // ParseDeviceFromUA 从浏览器原生 User-Agent 解析设备信息(服务端无 navigator)。 func ParseDeviceFromUA(ua string) (brand, model, deviceType, systemName, systemVersion string) { ua = strings.TrimSpace(ua) switch { case strings.Contains(ua, "iPhone") || strings.Contains(ua, "iPad") || strings.Contains(ua, "iPod"): systemName = "iOS" brand = "Apple" if m := reIOSVer.FindStringSubmatch(ua); len(m) > 1 { systemVersion = strings.ReplaceAll(m[1], "_", ".") } switch { case strings.Contains(ua, "iPad"): model, deviceType = "iPad", "tablet" case strings.Contains(ua, "iPod"): model, deviceType = "iPod", "mobile" default: model, deviceType = "iPhone", "mobile" } case strings.Contains(ua, "Android"): systemName = "Android" if m := reAndroidVer.FindStringSubmatch(ua); len(m) > 1 { systemVersion = m[1] } if m := reAndroidModel.FindStringSubmatch(ua); len(m) > 1 { model = strings.TrimSpace(m[1]) } brand = guessAndroidBrand(model, ua) if strings.Contains(ua, "Mobile") { deviceType = "mobile" } else { deviceType = "tablet" } case strings.Contains(ua, "Windows"): systemName = "Windows" deviceType = "desktop" if m := reWinNT.FindStringSubmatch(ua); len(m) > 1 { systemVersion = m[1] } case strings.Contains(ua, "Mac OS X") || strings.Contains(ua, "Macintosh"): systemName = "macOS" brand = "Apple" model = "Mac" deviceType = "desktop" if m := reMacVer.FindStringSubmatch(ua); len(m) > 1 { systemVersion = strings.ReplaceAll(m[1], "_", ".") } case strings.Contains(ua, "Linux"): systemName = "Linux" deviceType = "desktop" default: systemName = "Other" deviceType = "desktop" } return } func guessAndroidBrand(model, ua string) string { s := model + " " + ua rules := []struct { re *regexp.Regexp brand string }{ {regexp.MustCompile(`(?i)Build/HONOR|\bHONOR\b`), "Honor"}, {regexp.MustCompile(`(?i)Build/HUAWEI|HUAWEI|HW-`), "Huawei"}, {regexp.MustCompile(`(?i)SM-|samsung|Galaxy`), "Samsung"}, {regexp.MustCompile(`(?i)Pixel`), "Google"}, {regexp.MustCompile(`(?i)Redmi|POCO|XiaoMi|\bMi\b`), "Xiaomi"}, {regexp.MustCompile(`(?i)OPPO|\bCPH\d{3,4}`), "OPPO"}, {regexp.MustCompile(`(?i)vivo|\bV\d{4}`), "vivo"}, {regexp.MustCompile(`(?i)OnePlus|ONEPLUS`), "OnePlus"}, {regexp.MustCompile(`(?i)realme|\bRMX\d{3,4}`), "realme"}, } for _, r := range rules { if r.re.MatchString(s) { return r.brand } } return "" } func detectSysType(ua string) string { uaLower := strings.ToLower(ua) switch { case strings.Contains(uaLower, "android"): return "h5_android" case strings.Contains(uaLower, "iphone"), strings.Contains(uaLower, "ipad"), strings.Contains(uaLower, "ipod"): return "h5_ios" default: return "h5_pc" } } func parseBrowser(ua string) (name, version string) { switch { case reEdge.MatchString(ua): m := reEdge.FindStringSubmatch(ua) return "Edge", m[1] case reOpera.MatchString(ua): m := reOpera.FindStringSubmatch(ua) return "Opera", m[1] case reChrome.MatchString(ua) && !reEdge.MatchString(ua): m := reChrome.FindStringSubmatch(ua) return "Chrome", m[1] case reSafari.MatchString(ua): m := reSafari.FindStringSubmatch(ua) return "Safari", m[1] case reFirefox.MatchString(ua): m := reFirefox.FindStringSubmatch(ua) return "Firefox", m[1] case reIE.MatchString(ua): m := reIE.FindStringSubmatch(ua) return "IE", m[1] default: return "", "" } } func buildDevType(brand, model, deviceType string) string { parts := make([]string, 0, 3) for _, v := range []string{brand, model, deviceType} { v = strings.TrimSpace(v) if v != "" { parts = append(parts, v) } } if len(parts) < 3 { return "Apple iPhone mobile" } return strings.Join(parts, " ") } // BuildInfo 根据原始 UA 组装结构化信息。 func BuildInfo(opts BuildOptions) Info { ua := strings.TrimSpace(opts.RawUA) ver := strings.TrimSpace(opts.Ver) if ver == "" { ver = DefaultVer } brand, model, deviceType, systemName, systemVersion := ParseDeviceFromUA(ua) browserName, browserVer := parseBrowser(ua) buildID := strings.TrimSpace(strings.TrimSpace(browserName + " " + browserVer)) return Info{ RawUA: ua, DevType: buildDevType(brand, model, deviceType), SysType: detectSysType(ua), Ver: ver, BuildID: buildID, DeviceBrand: brand, DeviceModel: model, SystemName: systemName, SystemVersion: systemVersion, SID: strings.TrimSpace(opts.SID), } } // String 输出 X-User-Agent Header 值。 func (info Info) String() string { return fmt.Sprintf( "DevType=%s;SysType=%s;Ver=%s;BuildID=%s;device_brand=%s;device_model=%s;system_name=%s;system_version=%s;sid=%s", info.DevType, info.SysType, info.Ver, info.BuildID, info.DeviceBrand, info.DeviceModel, info.SystemName, info.SystemVersion, info.SID, ) } // BuildXUserAgent 直接返回 Header 字符串。 func BuildXUserAgent(opts BuildOptions) string { return BuildInfo(opts).String() } // SIDFromRequest 从 cookie 读取 sid(与前端约定:sid / _s_token_)。 func SIDFromRequest(r *http.Request) string { if r == nil { return "" } if c, err := r.Cookie("sid"); err == nil && c != nil { if v := strings.TrimSpace(c.Value); v != "" { return v } } if c, err := r.Cookie("_s_token_"); err == nil && c != nil { v := strings.TrimSpace(c.Value) // 可能是 JSON:{"sid":"..."} 或纯字符串 if strings.HasPrefix(v, "{") { const key = `"sid"` if i := strings.Index(v, key); i >= 0 { rest := v[i+len(key):] if j := strings.Index(rest, `"`); j >= 0 { rest = rest[j+1:] if k := strings.Index(rest, `"`); k >= 0 { return rest[:k] } } } } return v } return "" } // BuildXUserAgentFromRequest 供 SSR 反向代理上游 API 使用。 // 优先透传客户端已带的 X-User-Agent;否则用浏览器 UA 组装。 func BuildXUserAgentFromRequest(c *gin.Context, ver string) string { if c == nil || c.Request == nil { return BuildXUserAgent(BuildOptions{Ver: ver}) } if xua := strings.TrimSpace(c.GetHeader("X-User-Agent")); xua != "" { return xua } return BuildXUserAgent(BuildOptions{ Ver: ver, SID: SIDFromRequest(c.Request), RawUA: c.Request.UserAgent(), }) } // TokenFromRequest 从 cookie 读取登录 token(与前端约定:token / _token_)。 func TokenFromRequest(r *http.Request) string { if r == nil { return "" } for _, name := range []string{"token", "_token_"} { if c, err := r.Cookie(name); err == nil && c != nil { if v := cookieUnescape(c.Value); v != "" { return v } } } return "" } func cookieUnescape(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } if decoded, err := url.QueryUnescape(raw); err == nil { return strings.TrimSpace(decoded) } return raw } // ClientIPFromRequest 优先透传已有 X-Real-IP / X-Forwarded-For,否则用远端地址。 func ClientIPFromRequest(c *gin.Context) string { if c == nil || c.Request == nil { return "" } if ip := strings.TrimSpace(c.GetHeader("X-Real-IP")); ip != "" { return ip } if xff := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); xff != "" { if i := strings.Index(xff, ","); i >= 0 { return strings.TrimSpace(xff[:i]) } return xff } return strings.TrimSpace(c.ClientIP()) } // BuildUpstreamHeader 组装上游 API 请求头:X-Real-IP、User-Agent、X-User-Agent。 // 登录 token / sid 已在 cookie 中时由调用方按需写入 Authorization 等字段。 func BuildUpstreamHeader(c *gin.Context, ver string) map[string]string { header := make(map[string]string, 8) ApplyToHeader(header, c, ver) return header } // ApplyToHeader 将上游必需头写入 header map: // X-Real-IP、User-Agent、X-User-Agent。 func ApplyToHeader(header map[string]string, c *gin.Context, ver string) { if header == nil { return } if c != nil && c.Request != nil { if ip := ClientIPFromRequest(c); ip != "" { header["X-Real-IP"] = ip } if ua := strings.TrimSpace(c.Request.UserAgent()); ua != "" { header["User-Agent"] = ua } } header["X-User-Agent"] = BuildXUserAgentFromRequest(c, ver) }