Initial commit
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package useragent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildXUserAgent_iPhone(t *testing.T) {
|
||||
ua := "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
|
||||
got := BuildXUserAgent(BuildOptions{RawUA: ua, Ver: "1.0.0", SID: "abc"})
|
||||
wantParts := []string{
|
||||
"DevType=Apple iPhone mobile",
|
||||
"SysType=h5_ios",
|
||||
"Ver=1.0.0",
|
||||
"BuildID=Safari 17.2",
|
||||
"device_brand=Apple",
|
||||
"device_model=iPhone",
|
||||
"system_name=iOS",
|
||||
"system_version=17.2",
|
||||
"sid=abc",
|
||||
}
|
||||
for _, p := range wantParts {
|
||||
if !strings.Contains(got, p) {
|
||||
t.Fatalf("missing %q in %s", p, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildXUserAgent_Android(t *testing.T) {
|
||||
ua := "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||
got := BuildXUserAgent(BuildOptions{RawUA: ua, Ver: "1.0.0", SID: "s1"})
|
||||
wantParts := []string{
|
||||
"SysType=h5_android",
|
||||
"device_brand=Google",
|
||||
"device_model=Pixel 7",
|
||||
"system_name=Android",
|
||||
"system_version=13",
|
||||
"BuildID=Chrome 120.0.0.0",
|
||||
"sid=s1",
|
||||
}
|
||||
for _, p := range wantParts {
|
||||
if !strings.Contains(got, p) {
|
||||
t.Fatalf("missing %q in %s", p, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildXUserAgent_EmptyFallsBackDevType(t *testing.T) {
|
||||
got := BuildXUserAgent(BuildOptions{RawUA: "", Ver: "1.0.0"})
|
||||
if !strings.Contains(got, "DevType=Apple iPhone mobile") || !strings.Contains(got, "SysType=h5_pc") {
|
||||
t.Fatalf("unexpected fallback: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package useragent
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"videoweb/pub/common/enum/consts"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type UA struct {
|
||||
DevID string
|
||||
DevType string
|
||||
SysType string
|
||||
Ver string
|
||||
BuildID string
|
||||
}
|
||||
|
||||
func IsH5(sysType string) bool {
|
||||
if sysType == consts.H5IOS || sysType == consts.H5Android || sysType == consts.H5PC || sysType == consts.H5 || sysType == consts.Light {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s UA) IsH5() bool {
|
||||
return IsH5(s.SysType)
|
||||
}
|
||||
|
||||
func UserAgent(c *gin.Context) {
|
||||
var uastr string
|
||||
ua := UA{}
|
||||
userAgent := c.GetHeader("X-User-Agent") //兼容H5 UserAgent
|
||||
if userAgent == "" {
|
||||
userAgent = c.Request.UserAgent()
|
||||
}
|
||||
if userAgent != "" {
|
||||
// "BuildID=com.abc.Butterfly;SysType=h5;DevID=B9A00938-FE68-4686-9461-A365C7E5110D;Ver=0.0.1;DevType=iPhone;"
|
||||
decodeUa, err := url.QueryUnescape(userAgent)
|
||||
if err != nil {
|
||||
log.Warn("URLDecode user-agent error", log.Any("ua", userAgent))
|
||||
uastr = userAgent
|
||||
} else {
|
||||
uastr = decodeUa
|
||||
}
|
||||
uas := strings.Split(uastr, ";")
|
||||
for _, v := range uas {
|
||||
vss := strings.Split(strings.TrimSpace(v), "=")
|
||||
if len(vss) < 2 {
|
||||
c.Set(consts.CtxUAH5, vss[0])
|
||||
// log.Warn("user-agent miss", log.Any("ua", userAgent), log.Any("v", v))
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(vss[0]) {
|
||||
case "DevID":
|
||||
ua.DevID = strings.TrimSpace(vss[1])
|
||||
c.Set(consts.CtxDevId, ua.DevID)
|
||||
case "DevType":
|
||||
ua.DevType = strings.TrimSpace(vss[1])
|
||||
c.Set(consts.CtxDevType, ua.DevType)
|
||||
case "SysType":
|
||||
IntputSysType := strings.TrimSpace(vss[1])
|
||||
if strings.Contains(IntputSysType, "h5_android") {
|
||||
ua.SysType = consts.H5Android
|
||||
} else if strings.Contains(IntputSysType, "h5_ios") {
|
||||
ua.SysType = consts.H5IOS
|
||||
} else if strings.Contains(IntputSysType, "h5_pc") {
|
||||
ua.SysType = consts.H5PC
|
||||
} else if strings.Contains(IntputSysType, "android") {
|
||||
ua.SysType = consts.Android
|
||||
} else if strings.Contains(IntputSysType, "ios") {
|
||||
ua.SysType = consts.IOS
|
||||
} else if strings.Contains(IntputSysType, "h5") {
|
||||
ua.SysType = consts.H5
|
||||
} else if strings.Contains(IntputSysType, "pc") {
|
||||
ua.SysType = consts.PC
|
||||
} else if strings.Contains(IntputSysType, "light") {
|
||||
ua.SysType = consts.Light
|
||||
}
|
||||
c.Set(consts.CtxSysType, ua.SysType)
|
||||
case "Ver":
|
||||
ua.Ver = strings.TrimSpace(vss[1])
|
||||
c.Set(consts.CtxVersion, ua.Ver)
|
||||
case "BuildID":
|
||||
ua.BuildID = strings.TrimSpace(vss[1])
|
||||
c.Set(consts.CtxBuildId, ua.BuildID)
|
||||
default:
|
||||
log.Warn("user-agent unknown", log.Any("ua", userAgent))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Warn("user-agent empty")
|
||||
}
|
||||
c.Set(consts.CtxUA, ua)
|
||||
}
|
||||
Reference in New Issue
Block a user