Files
web_huangguo2_h5/pub/common/useragent/build.go
T
谢宇宁andClaude Opus 5.5 0fbdba5ccf Initial commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:47:47 +07:00

543 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}