@@ -0,0 +1,542 @@
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
}