package adtracking import ( "context" "errors" "fmt" "net/url" "regexp" "strings" "time" "github.com/redis/go-redis/v9" ) var ( ErrLinkNotFound = errors.New("推广链接不存在") ErrLinkDisabled = errors.New("该推广链接已被停用") ErrCodeAlreadyExists = errors.New("该自定义后缀已被占用,请更换") ErrInvalidCodeFormat = errors.New("后缀格式不正确,仅支持2-64位英文、数字、下划线和连字符") ErrReservedCode = errors.New("该后缀为系统保留字,不可使用") ErrDisallowedTargetDomain = errors.New("目标落地页仅允许为 hao858.com 及其子域名或站内路径") codePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{2,64}$`) ) var reservedCodes = map[string]bool{ "admin": true, "api": true, "static": true, "assets": true, "login": true, "register": true, "auth": true, "user": true, "order": true, "r": true, "pay": true, "notice": true, "chat": true, "dashboard": true, } func validateTargetURL(rawURL string, allowLocal bool) (string, error) { targetURL := strings.TrimSpace(rawURL) if targetURL == "" { return "https://hao858.com/", nil } if strings.HasPrefix(targetURL, "/") { return "https://hao858.com" + targetURL, nil } if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") { targetURL = "https://" + targetURL } u, err := url.Parse(targetURL) if err != nil || u.Host == "" { return "", errors.New("目标落地页地址不合法") } scheme := strings.ToLower(u.Scheme) if scheme != "http" && scheme != "https" { return "", errors.New("目标落地页地址不合法") } host := strings.ToLower(u.Hostname()) if host == "localhost" || host == "127.0.0.1" { if !allowLocal { return "", ErrDisallowedTargetDomain } return targetURL, nil } if host != "hao858.com" && !strings.HasSuffix(host, ".hao858.com") { return "", ErrDisallowedTargetDomain } return targetURL, nil } type Service struct { repo *Repository redis *redis.Client allowLocalTargets bool } func NewService(repo *Repository, redisClient *redis.Client, allowLocalTargets bool) *Service { return &Service{ repo: repo, redis: redisClient, allowLocalTargets: allowLocalTargets, } } func (s *Service) CreateLink(ctx context.Context, adminID uint64, req CreateTrackingLinkRequest) (*AdTrackingLink, error) { code := strings.TrimSpace(req.Code) if !codePattern.MatchString(code) { return nil, ErrInvalidCodeFormat } if reservedCodes[strings.ToLower(code)] { return nil, ErrReservedCode } // 查重 existing, _ := s.repo.FindLinkByCode(ctx, code) if existing != nil { return nil, ErrCodeAlreadyExists } targetURL, err := validateTargetURL(req.TargetURL, s.allowLocalTargets) if err != nil { return nil, err } channelType := strings.TrimSpace(req.ChannelType) if channelType == "" { channelType = "offline_promo" } link := &AdTrackingLink{ Name: strings.TrimSpace(req.Name), Code: code, ChannelType: channelType, TargetURL: targetURL, Status: "active", CostCent: req.CostCent, Notes: strings.TrimSpace(req.Notes), CreatedBy: adminID, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if err := s.repo.CreateLink(ctx, link); err != nil { return nil, fmt.Errorf("创建推广链接失败: %w", err) } return link, nil } func (s *Service) UpdateLink(ctx context.Context, id uint64, req UpdateTrackingLinkRequest) error { link, err := s.repo.FindLinkByID(ctx, id) if err != nil { return ErrLinkNotFound } targetURL, err := validateTargetURL(req.TargetURL, s.allowLocalTargets) if err != nil { return err } channelType := strings.TrimSpace(req.ChannelType) if channelType == "" { channelType = link.ChannelType } updates := map[string]interface{}{ "name": strings.TrimSpace(req.Name), "channel_type": channelType, "target_url": targetURL, "cost_cent": req.CostCent, "notes": strings.TrimSpace(req.Notes), "updated_at": time.Now(), } return s.repo.UpdateLink(ctx, id, updates) } func (s *Service) UpdateStatus(ctx context.Context, id uint64, status string) error { if status != "active" && status != "disabled" { return errors.New("状态值不合法") } _, err := s.repo.FindLinkByID(ctx, id) if err != nil { return ErrLinkNotFound } return s.repo.UpdateLink(ctx, id, map[string]interface{}{ "status": status, "updated_at": time.Now(), }) } func (s *Service) ListLinks(ctx context.Context, query ListTrackingLinksQuery, baseURL string) ([]TrackingLinkItem, int64, error) { return s.repo.ListLinks(ctx, query, baseURL) } func (s *Service) GetOverview(ctx context.Context) (*TrackingOverviewStats, error) { return s.repo.GetOverviewStats(ctx) } // ResolveRedirect 处理 302 短链跳转,并记录点击流水 func (s *Service) ResolveRedirect(ctx context.Context, code, ip, ua, referer, visitorID string) (string, error) { link, err := s.repo.FindLinkByCode(ctx, code) if err != nil { return "", ErrLinkNotFound } if link.Status != "active" { return "", ErrLinkDisabled } // 异步记录点击流水,防止写库阻塞跳转 deviceType, os, browser := parseUserAgent(ua) go func() { defer func() { if r := recover(); r != nil { // 保护主进程,防止异步协程 panic } }() bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // 防刷:同访客 5 秒去重;同 IP 1 秒去重,避免不带 Cookie 轮换 visitorID 刷库 if s.redis != nil { if isDuplicateClick(bgCtx, s.redis, "adtrack:dedup:v:"+link.Code+":"+visitorID, 5*time.Second) { return } if isDuplicateClick(bgCtx, s.redis, "adtrack:dedup:ip:"+link.Code+":"+ip, time.Second) { return } } click := &AdTrackingClick{ LinkID: link.ID, Code: link.Code, IP: ip, UserAgent: truncateString(ua, 500), DeviceType: deviceType, OS: os, Browser: browser, Referer: truncateString(referer, 500), VisitorID: visitorID, CreatedAt: time.Now(), } _ = s.repo.RecordClick(bgCtx, click) }() // 拼接带推广短码的落地页 URL (例如 https://hao858.com/?track_code=wb01) finalURL := appendTrackCode(link.TargetURL, link.Code) return finalURL, nil } func isDuplicateClick(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) bool { if rdb == nil || key == "" || strings.HasSuffix(key, ":") { return false } ok, err := rdb.SetNX(ctx, key, "1", ttl).Result() return err == nil && !ok } func appendTrackCode(targetURL, code string) string { u, err := url.Parse(targetURL) if err != nil { return targetURL } q := u.Query() q.Set("track_code", code) u.RawQuery = q.Encode() return u.String() } func truncateString(str string, maxLen int) string { if len(str) > maxLen { return str[:maxLen] } return str } func parseUserAgent(ua string) (deviceType, os, browser string) { deviceType = "pc" os = "unknown" browser = "unknown" lowerUA := strings.ToLower(ua) if strings.Contains(lowerUA, "mobile") || strings.Contains(lowerUA, "android") || strings.Contains(lowerUA, "iphone") { deviceType = "mobile" } else if strings.Contains(lowerUA, "ipad") || strings.Contains(lowerUA, "tablet") { deviceType = "tablet" } // OS 检测 (优先移动端避免 iPhone UA 中的 Mac OS X 干扰) if strings.Contains(lowerUA, "iphone") || strings.Contains(lowerUA, "ios") { os = "iOS" } else if strings.Contains(lowerUA, "android") { os = "Android" } else if strings.Contains(lowerUA, "windows") { os = "Windows" } else if strings.Contains(lowerUA, "mac os") || strings.Contains(lowerUA, "macintosh") { os = "macOS" } else if strings.Contains(lowerUA, "linux") { os = "Linux" } // Browser 检测 if strings.Contains(lowerUA, "micromessenger") { browser = "WeChat" } else if strings.Contains(lowerUA, "edg/") || strings.Contains(lowerUA, "edge/") { browser = "Edge" } else if strings.Contains(lowerUA, "chrome") { browser = "Chrome" } else if strings.Contains(lowerUA, "safari") { browser = "Safari" } else if strings.Contains(lowerUA, "firefox") { browser = "Firefox" } return deviceType, os, browser }