543 lines
16 KiB
Go
543 lines
16 KiB
Go
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
|
||
}
|