Files
hfb_sys/backend/internal/modules/adtracking/service.go
T
yml2213andCursor ee0e70ccad fix(adtracking): 收紧成交口径、点击去重与生产落地页白名单
成交 GMV 排除已关闭订单,避免未支付关单被计入;点击按访客 5 秒、IP 1 秒双重去重;生产禁止 localhost 落地页,失效短码改用 QueryEscape。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 11:27:43 +08:00

290 lines
8.1 KiB
Go

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
}