成交 GMV 排除已关闭订单,避免未支付关单被计入;点击按访客 5 秒、IP 1 秒双重去重;生产禁止 localhost 落地页,失效短码改用 QueryEscape。 Co-authored-by: Cursor <cursoragent@cursor.com>
290 lines
8.1 KiB
Go
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
|
|
}
|