fix(adtracking): 收紧成交口径、点击去重与生产落地页白名单

成交 GMV 排除已关闭订单,避免未支付关单被计入;点击按访客 5 秒、IP 1 秒双重去重;生产禁止 localhost 落地页,失效短码改用 QueryEscape。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-10 11:27:43 +08:00
co-authored by Cursor
parent 6a3611c1a8
commit ee0e70ccad
6 changed files with 73 additions and 52 deletions
@@ -5,6 +5,7 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
@@ -25,7 +26,7 @@ func NewHandler(service *Service) *Handler {
// Redirect 公开短链 302 智能重定向 // Redirect 公开短链 302 智能重定向
func (h *Handler) Redirect(c *gin.Context) { func (h *Handler) Redirect(c *gin.Context) {
code := strings.TrimSpace(c.Param("code")) code := strings.TrimSpace(c.Param("code"))
if code == "" { if !codePattern.MatchString(code) {
c.Redirect(http.StatusFound, "https://hao858.com/") c.Redirect(http.StatusFound, "https://hao858.com/")
return return
} }
@@ -45,7 +46,7 @@ func (h *Handler) Redirect(c *gin.Context) {
targetURL, err := h.service.ResolveRedirect(c.Request.Context(), code, ip, ua, referer, visitorID) targetURL, err := h.service.ResolveRedirect(c.Request.Context(), code, ip, ua, referer, visitorID)
if err != nil { if err != nil {
// 链接不存在或已停用时,优雅兜底跳转至官网首页,附带失效短码参数以备提示 // 链接不存在或已停用时,优雅兜底跳转至官网首页,附带失效短码参数以备提示
c.Redirect(http.StatusFound, "https://hao858.com/?ref_invalid="+urlEscape(code)) c.Redirect(http.StatusFound, "https://hao858.com/?ref_invalid="+url.QueryEscape(code))
return return
} }
@@ -171,10 +172,6 @@ func generateVisitorID() string {
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
func urlEscape(s string) string {
return strings.ReplaceAll(s, " ", "%20")
}
func getRequestBaseURL(c *gin.Context) string { func getRequestBaseURL(c *gin.Context) string {
scheme := "https" scheme := "https"
if proto := c.Request.Header.Get("X-Forwarded-Proto"); proto != "" { if proto := c.Request.Header.Get("X-Forwarded-Proto"); proto != "" {
@@ -129,15 +129,15 @@ func (r *Repository) ListLinks(ctx context.Context, query ListTrackingLinksQuery
// 3. 批量聚合订单数据 (总订单数, 成交订单数, 成交GMV) // 3. 批量聚合订单数据 (总订单数, 成交订单数, 成交GMV)
type orderStat struct { type orderStat struct {
TrackCode string `gorm:"column:source_track_code"` TrackCode string `gorm:"column:source_track_code"`
OrderCount int64 `gorm:"column:order_count"` OrderCount int64 `gorm:"column:order_count"`
PaidCount int64 `gorm:"column:paid_order_count"` PaidCount int64 `gorm:"column:paid_order_count"`
PaidGMVCent int64 `gorm:"column:paid_gmv_cent"` PaidGMVCent int64 `gorm:"column:paid_gmv_cent"`
} }
var orderStats []orderStat var orderStats []orderStat
_ = r.db.WithContext(ctx). _ = r.db.WithContext(ctx).
Table("rental_orders"). Table("rental_orders").
Select("source_track_code, COUNT(*) as order_count, SUM(CASE WHEN status NOT IN ('pending_payment', 'cancelled') THEN 1 ELSE 0 END) as paid_order_count, COALESCE(SUM(CASE WHEN status NOT IN ('pending_payment', 'cancelled') THEN rent_amount_cent ELSE 0 END), 0) as paid_gmv_cent"). Select("source_track_code, COUNT(*) as order_count, SUM(CASE WHEN status NOT IN ('pending_payment', 'cancelled', 'closed') THEN 1 ELSE 0 END) as paid_order_count, COALESCE(SUM(CASE WHEN status NOT IN ('pending_payment', 'cancelled', 'closed') THEN rent_amount_cent ELSE 0 END), 0) as paid_gmv_cent").
Where("source_track_code IN (?)", codes). Where("source_track_code IN (?)", codes).
Group("source_track_code"). Group("source_track_code").
Scan(&orderStats).Error Scan(&orderStats).Error
@@ -226,7 +226,7 @@ func (r *Repository) GetOverviewStats(ctx context.Context) (*TrackingOverviewSta
var ot orderTotal var ot orderTotal
_ = r.db.WithContext(ctx). _ = r.db.WithContext(ctx).
Table("rental_orders"). Table("rental_orders").
Select("COUNT(*) as total_orders, SUM(CASE WHEN status NOT IN ('pending_payment', 'cancelled') THEN 1 ELSE 0 END) as total_paid_orders, COALESCE(SUM(CASE WHEN status NOT IN ('pending_payment', 'cancelled') THEN rent_amount_cent ELSE 0 END), 0) as total_paid_gmv"). Select("COUNT(*) as total_orders, SUM(CASE WHEN status NOT IN ('pending_payment', 'cancelled', 'closed') THEN 1 ELSE 0 END) as total_paid_orders, COALESCE(SUM(CASE WHEN status NOT IN ('pending_payment', 'cancelled', 'closed') THEN rent_amount_cent ELSE 0 END), 0) as total_paid_gmv").
Where("source_track_code IS NOT NULL AND source_track_code != ''"). Where("source_track_code IS NOT NULL AND source_track_code != ''").
Scan(&ot).Error Scan(&ot).Error
stats.TotalOrders = ot.TotalOrders stats.TotalOrders = ot.TotalOrders
@@ -257,7 +257,7 @@ func (r *Repository) GetOverviewStats(ctx context.Context) (*TrackingOverviewSta
var todayOt orderTotal var todayOt orderTotal
_ = r.db.WithContext(ctx). _ = r.db.WithContext(ctx).
Table("rental_orders"). Table("rental_orders").
Select("COUNT(*) as total_orders, COALESCE(SUM(CASE WHEN status NOT IN ('pending_payment', 'cancelled') THEN rent_amount_cent ELSE 0 END), 0) as total_paid_gmv"). Select("COUNT(*) as total_orders, COALESCE(SUM(CASE WHEN status NOT IN ('pending_payment', 'cancelled', 'closed') THEN rent_amount_cent ELSE 0 END), 0) as total_paid_gmv").
Where("source_track_code IS NOT NULL AND source_track_code != '' AND created_at >= ?", todayStart). Where("source_track_code IS NOT NULL AND source_track_code != '' AND created_at >= ?", todayStart).
Scan(&todayOt).Error Scan(&todayOt).Error
stats.TodayOrders = todayOt.TotalOrders stats.TodayOrders = todayOt.TotalOrders
+35 -20
View File
@@ -28,7 +28,7 @@ var reservedCodes = map[string]bool{
"pay": true, "notice": true, "chat": true, "dashboard": true, "pay": true, "notice": true, "chat": true, "dashboard": true,
} }
func validateTargetURL(rawURL string) (string, error) { func validateTargetURL(rawURL string, allowLocal bool) (string, error) {
targetURL := strings.TrimSpace(rawURL) targetURL := strings.TrimSpace(rawURL)
if targetURL == "" { if targetURL == "" {
return "https://hao858.com/", nil return "https://hao858.com/", nil
@@ -40,25 +40,37 @@ func validateTargetURL(rawURL string) (string, error) {
targetURL = "https://" + targetURL targetURL = "https://" + targetURL
} }
u, err := url.Parse(targetURL) u, err := url.Parse(targetURL)
if err != nil { if err != nil || u.Host == "" {
return "", errors.New("目标落地页地址不合法")
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return "", errors.New("目标落地页地址不合法") return "", errors.New("目标落地页地址不合法")
} }
host := strings.ToLower(u.Hostname()) host := strings.ToLower(u.Hostname())
if host != "hao858.com" && !strings.HasSuffix(host, ".hao858.com") && host != "localhost" && host != "127.0.0.1" { 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 "", ErrDisallowedTargetDomain
} }
return targetURL, nil return targetURL, nil
} }
type Service struct { type Service struct {
repo *Repository repo *Repository
redis *redis.Client redis *redis.Client
allowLocalTargets bool
} }
func NewService(repo *Repository, redisClient *redis.Client) *Service { func NewService(repo *Repository, redisClient *redis.Client, allowLocalTargets bool) *Service {
return &Service{ return &Service{
repo: repo, repo: repo,
redis: redisClient, redis: redisClient,
allowLocalTargets: allowLocalTargets,
} }
} }
@@ -77,7 +89,7 @@ func (s *Service) CreateLink(ctx context.Context, adminID uint64, req CreateTrac
return nil, ErrCodeAlreadyExists return nil, ErrCodeAlreadyExists
} }
targetURL, err := validateTargetURL(req.TargetURL) targetURL, err := validateTargetURL(req.TargetURL, s.allowLocalTargets)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -113,7 +125,7 @@ func (s *Service) UpdateLink(ctx context.Context, id uint64, req UpdateTrackingL
return ErrLinkNotFound return ErrLinkNotFound
} }
targetURL, err := validateTargetURL(req.TargetURL) targetURL, err := validateTargetURL(req.TargetURL, s.allowLocalTargets)
if err != nil { if err != nil {
return err return err
} }
@@ -179,18 +191,13 @@ func (s *Service) ResolveRedirect(ctx context.Context, code, ip, ua, referer, vi
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
// 防刷频控:同一个访客/IP在 5 秒内重复点击相同短链,不重复记录流水 // 防刷:同访客 5 秒去重;同 IP 1 秒去重,避免不带 Cookie 轮换 visitorID 刷库
if s.redis != nil { if s.redis != nil {
dedupID := visitorID if isDuplicateClick(bgCtx, s.redis, "adtrack:dedup:v:"+link.Code+":"+visitorID, 5*time.Second) {
if dedupID == "" { return
dedupID = ip
} }
if dedupID != "" { if isDuplicateClick(bgCtx, s.redis, "adtrack:dedup:ip:"+link.Code+":"+ip, time.Second) {
dedupKey := "adtrack:dedup:" + link.Code + ":" + dedupID return
ok, rErr := s.redis.SetNX(bgCtx, dedupKey, "1", 5*time.Second).Result()
if rErr == nil && !ok {
return
}
} }
} }
@@ -214,6 +221,14 @@ func (s *Service) ResolveRedirect(ctx context.Context, code, ip, ua, referer, vi
return finalURL, nil 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 { func appendTrackCode(targetURL, code string) string {
u, err := url.Parse(targetURL) u, err := url.Parse(targetURL)
if err != nil { if err != nil {
@@ -92,38 +92,40 @@ func TestCodePattern(t *testing.T) {
func TestValidateTargetURL(t *testing.T) { func TestValidateTargetURL(t *testing.T) {
tests := []struct { tests := []struct {
input string input string
allowLocal bool
wantURL string wantURL string
wantErr bool wantErr bool
errExpected error errExpected error
}{ }{
{"", "https://hao858.com/", false, nil}, {"", false, "https://hao858.com/", false, nil},
{"/listings", "https://hao858.com/listings", false, nil}, {"/listings", false, "https://hao858.com/listings", false, nil},
{"https://hao858.com", "https://hao858.com", false, nil}, {"https://hao858.com", false, "https://hao858.com", false, nil},
{"https://h5.hao858.com/m", "https://h5.hao858.com/m", false, nil}, {"https://h5.hao858.com/m", false, "https://h5.hao858.com/m", false, nil},
{"http://localhost:5189", "http://localhost:5189", false, nil}, {"http://localhost:5189", true, "http://localhost:5189", false, nil},
{"http://127.0.0.1:18088", "http://127.0.0.1:18088", false, nil}, {"http://127.0.0.1:18088", true, "http://127.0.0.1:18088", false, nil},
{"https://evil-phishing.com", "", true, ErrDisallowedTargetDomain}, {"http://localhost:5189", false, "", true, ErrDisallowedTargetDomain},
{"https://hao858.com.fake.com", "", true, ErrDisallowedTargetDomain}, {"http://127.0.0.1:18088", false, "", true, ErrDisallowedTargetDomain},
{"https://sub.evil.com/hao858.com", "", true, ErrDisallowedTargetDomain}, {"https://evil-phishing.com", false, "", true, ErrDisallowedTargetDomain},
{"https://hao858.com.fake.com", false, "", true, ErrDisallowedTargetDomain},
{"https://sub.evil.com/hao858.com", false, "", true, ErrDisallowedTargetDomain},
} }
for _, tt := range tests { for _, tt := range tests {
got, err := validateTargetURL(tt.input) got, err := validateTargetURL(tt.input, tt.allowLocal)
if tt.wantErr { if tt.wantErr {
if err == nil { if err == nil {
t.Errorf("validateTargetURL(%q) expected error, got nil", tt.input) t.Errorf("validateTargetURL(%q, %v) expected error, got nil", tt.input, tt.allowLocal)
} }
if tt.errExpected != nil && err != tt.errExpected { if tt.errExpected != nil && err != tt.errExpected {
t.Errorf("validateTargetURL(%q) err = %v; want %v", tt.input, err, tt.errExpected) t.Errorf("validateTargetURL(%q, %v) err = %v; want %v", tt.input, tt.allowLocal, err, tt.errExpected)
} }
} else { } else {
if err != nil { if err != nil {
t.Errorf("validateTargetURL(%q) unexpected error: %v", tt.input, err) t.Errorf("validateTargetURL(%q, %v) unexpected error: %v", tt.input, tt.allowLocal, err)
} }
if got != tt.wantURL { if got != tt.wantURL {
t.Errorf("validateTargetURL(%q) = %q; want %q", tt.input, got, tt.wantURL) t.Errorf("validateTargetURL(%q, %v) = %q; want %q", tt.input, tt.allowLocal, got, tt.wantURL)
} }
} }
} }
} }
+2 -2
View File
@@ -6,7 +6,6 @@ import (
"hfb_sys/backend/internal/config" "hfb_sys/backend/internal/config"
"hfb_sys/backend/internal/handler" "hfb_sys/backend/internal/handler"
"hfb_sys/backend/internal/middleware" "hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/modules/adtracking"
"hfb_sys/backend/internal/modules/adminaudit" "hfb_sys/backend/internal/modules/adminaudit"
"hfb_sys/backend/internal/modules/adminauth" "hfb_sys/backend/internal/modules/adminauth"
"hfb_sys/backend/internal/modules/admindashboard" "hfb_sys/backend/internal/modules/admindashboard"
@@ -16,6 +15,7 @@ import (
"hfb_sys/backend/internal/modules/adminpush" "hfb_sys/backend/internal/modules/adminpush"
"hfb_sys/backend/internal/modules/adminrole" "hfb_sys/backend/internal/modules/adminrole"
"hfb_sys/backend/internal/modules/adminuser" "hfb_sys/backend/internal/modules/adminuser"
"hfb_sys/backend/internal/modules/adtracking"
"hfb_sys/backend/internal/modules/announcement" "hfb_sys/backend/internal/modules/announcement"
"hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/internal/modules/auth"
"hfb_sys/backend/internal/modules/backupmonitor" "hfb_sys/backend/internal/modules/backupmonitor"
@@ -377,7 +377,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
if deps.DB != nil { if deps.DB != nil {
adTrackingRepo = adtracking.NewRepository(deps.DB) adTrackingRepo = adtracking.NewRepository(deps.DB)
} }
adTrackingService := adtracking.NewService(adTrackingRepo, deps.Redis) adTrackingService := adtracking.NewService(adTrackingRepo, deps.Redis, !config.IsProductionEnv(cfg.AppEnv))
adTrackingHandler := adtracking.NewHandler(adTrackingService) adTrackingHandler := adtracking.NewHandler(adTrackingService)
engine.GET("/r/:code", adTrackingHandler.Redirect) engine.GET("/r/:code", adTrackingHandler.Redirect)
validateUserToken := func(ctx context.Context, userID uint64, tokenVersion int64) error { validateUserToken := func(ctx context.Context, userID uint64, tokenVersion int64) error {
+9 -2
View File
@@ -1,14 +1,21 @@
const TRACK_CODE_KEY = 'hfb_track_code' const TRACK_CODE_KEY = 'hfb_track_code'
const TRACK_CODE_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天有效 const TRACK_CODE_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天有效
const TRACK_CODE_PATTERN = /^[a-zA-Z0-9_-]{2,64}$/
interface StoredTrackCode { interface StoredTrackCode {
code: string code: string
timestamp: number timestamp: number
} }
function sanitizeTrackCode(code: string): string {
const cleanCode = code.trim()
if (!TRACK_CODE_PATTERN.test(cleanCode)) return ''
return cleanCode
}
export function saveTrackCode(code: string): void { export function saveTrackCode(code: string): void {
if (!code) return if (!code) return
const cleanCode = code.trim() const cleanCode = sanitizeTrackCode(code)
if (!cleanCode) return if (!cleanCode) return
try { try {
const item: StoredTrackCode = { const item: StoredTrackCode = {
@@ -31,7 +38,7 @@ export function getTrackCode(): string {
localStorage.removeItem(TRACK_CODE_KEY) localStorage.removeItem(TRACK_CODE_KEY)
return '' return ''
} }
return item.code return sanitizeTrackCode(item.code)
} catch { } catch {
return '' return ''
} }