package useragent import ( "encoding/base64" "encoding/json" "fmt" "net/http" "net/url" "regexp" "strings" "unicode/utf8" "videoweb/app/appg" "github.com/gin-gonic/gin" ) const ( DefaultVer = "1.0.0" SDKCommonHeader = "X-SDK-Common" ) // 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.]+)`) reTrackerRequestID = regexp.MustCompile(`^[a-fA-F0-9]{32}$`) reTrackerCtx = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) reTrackerEvent = regexp.MustCompile(`^drama_(like|comment|collect|purchase)$`) reTrackerAppID = regexp.MustCompile(`^[A-Z]{1,6}-[0-9]{1,6}$`) reTrackerUID = regexp.MustCompile(`^[A-Za-z0-9_]+$`) reTrackerChannel = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) ) // 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) == 0 { return "OTHER" } 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, 40) ApplyToHeader(header, c, ver) return header } var trackerEventRoutes = map[string]string{ "/api/playlet/chapter/like/set": "drama_like", "/api/playlet/like/set": "drama_like", "/api/playlet/chapter/collect/set": "drama_collect", "/api/playlet/collect/set": "drama_collect", "/api/comment/send": "drama_comment", "/api/playlet/chapter/unlock": "drama_purchase", } var trackerCommonFieldKeys = map[string]struct{}{ "app_id": {}, "uid": {}, "channel": {}, "sid": {}, "client_ts": {}, "device": {}, "device_id": {}, "device_brand": {}, "device_model": {}, "device_fingerprint": {}, "fp_version": {}, "user_agent": {}, "sdk_version": {}, "app_version": {}, } var trackerDramaFieldKeys = map[string]struct{}{ "media_id": {}, "drama_id": {}, "episode": {}, "video_id": {}, "recommend_trace_id": {}, } type trackerContextEnvelope struct { Version int `json:"v"` RequestID string `json:"request_id"` EventName string `json:"event_name"` Common map[string]json.RawMessage `json:"common"` Drama map[string]json.RawMessage `json:"drama"` } // ApplyToHeader 将来源请求的网络信息与 SDK 原始公共字段快照写入上游 header。 // 埋点上下文只通过 canonical envelope 白名单透传,不在 BFF 重新生成或覆盖。 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) if c == nil || c.Request == nil { return } if sdk := strings.TrimSpace(c.GetHeader(SDKCommonHeader)); sdk != "" { header[SDKCommonHeader] = sdk } requestID := strings.TrimSpace(c.GetHeader("X-Tracker-Request-Id")) eventName := strings.TrimSpace(c.GetHeader("X-Tracker-Event-Name")) packedContext := strings.TrimSpace(c.GetHeader("X-DC-Request-Context")) expectedEvent := trackerEventRoutes[c.FullPath()] if expectedEvent == "" { expectedEvent = trackerEventRoutes[c.Request.URL.Path] } if c.Request.Method != http.MethodPost || expectedEvent == "" || eventName != expectedEvent || !reTrackerRequestID.MatchString(requestID) || !reTrackerEvent.MatchString(eventName) { return } canonicalContext, ok := canonicalPackedTrackerContext(packedContext, requestID, eventName) if !ok { return } header["X-DC-Request-Context"] = canonicalContext header["X-Tracker-Request-Id"] = requestID header["X-Tracker-Event-Name"] = eventName } func canonicalPackedTrackerContext(value, requestID, eventName string) (string, bool) { if value == "" || len(value) > 8192 || !reTrackerCtx.MatchString(value) { return "", false } raw, err := base64.RawURLEncoding.DecodeString(value) if err != nil || len(raw) > 6144 { return "", false } var envelope trackerContextEnvelope if err := json.Unmarshal(raw, &envelope); err != nil { return "", false } if envelope.Version != 1 || envelope.RequestID != requestID || envelope.EventName != eventName || !reTrackerRequestID.MatchString(envelope.RequestID) || !reTrackerEvent.MatchString(envelope.EventName) || !hasOnlyTrackerFields(envelope.Common, trackerCommonFieldKeys) || !hasOnlyTrackerFields(envelope.Drama, trackerDramaFieldKeys) || !validTrackerCommonFields(envelope.Common) || !validTrackerDramaFields(envelope.Drama) { return "", false } // 公共字段的值保持 SDK 原值;重新编码仅移除重复 JSON key,防止上下游解析差异。 canonical, err := json.Marshal(envelope) if err != nil || len(canonical) > 6144 { return "", false } return base64.RawURLEncoding.EncodeToString(canonical), true } func hasOnlyTrackerFields(values map[string]json.RawMessage, allowed map[string]struct{}) bool { if len(values) != len(allowed) { return false } for key := range values { if _, ok := allowed[key]; !ok { return false } } return true } func validTrackerCommonFields(values map[string]json.RawMessage) bool { appID, ok := trackerStringField(values, "app_id", 50, false) if !ok || !reTrackerAppID.MatchString(appID) || appID != appg.TrackerAppID() { return false } uid, ok := trackerStringField(values, "uid", 32, true) if !ok || (uid != "" && (utf8.RuneCountInString(uid) < 4 || !reTrackerUID.MatchString(uid) || uid == "u_1001" || uid == "stress_user" || uid == "u_lab")) { return false } channel, ok := trackerStringField(values, "channel", 50, true) if !ok || (channel != "" && (!reTrackerChannel.MatchString(channel) || channel == "{}" || channel == "0" || channel == "-" || strings.EqualFold(channel, "unknown"))) { return false } if _, ok = trackerStringField(values, "sid", 50, true); !ok { return false } clientTS, ok := trackerIntegerField(values, "client_ts") if !ok || clientTS < 1767196800 || clientTS > 9999999999 { return false } device, ok := trackerStringField(values, "device", 30, true) if !ok || (device != "" && device != "Android" && device != "iOS" && device != "PC" && device != "OTHER") { return false } if _, ok = trackerStringField(values, "device_id", 50, false); !ok { return false } if _, ok = trackerStringField(values, "device_brand", 100, true); !ok { return false } if _, ok = trackerStringField(values, "device_model", 100, true); !ok { return false } if _, ok = trackerStringField(values, "device_fingerprint", 128, true); !ok { return false } if _, ok = trackerStringField(values, "fp_version", 128, true); !ok { return false } if _, ok = trackerStringField(values, "user_agent", 4096, true); !ok { return false } if _, ok = trackerStringField(values, "sdk_version", 128, true); !ok { return false } _, ok = trackerStringField(values, "app_version", 128, true) return ok } func validTrackerDramaFields(values map[string]json.RawMessage) bool { if _, ok := trackerStringField(values, "media_id", 512, true); !ok { return false } if _, ok := trackerStringField(values, "drama_id", 512, false); !ok { return false } episode, ok := trackerIntegerField(values, "episode") if !ok || episode <= 0 { return false } if _, ok = trackerStringField(values, "video_id", 512, true); !ok { return false } _, ok = trackerStringField(values, "recommend_trace_id", 2048, true) return ok } func trackerStringField(values map[string]json.RawMessage, key string, maxLen int, allowEmpty bool) (string, bool) { raw, ok := values[key] if !ok { return "", false } var decoded *string if err := json.Unmarshal(raw, &decoded); err != nil || decoded == nil { return "", false } value := *decoded if utf8.RuneCountInString(value) > maxLen || (!allowEmpty && value == "") { return "", false } return value, true } func trackerIntegerField(values map[string]json.RawMessage, key string) (int64, bool) { raw, ok := values[key] if !ok { return 0, false } var decoded *int64 if err := json.Unmarshal(raw, &decoded); err != nil || decoded == nil { return 0, false } return *decoded, true }