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"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
@@ -25,7 +26,7 @@ func NewHandler(service *Service) *Handler {
// Redirect 公开短链 302 智能重定向
func (h *Handler) Redirect(c *gin.Context) {
code := strings.TrimSpace(c.Param("code"))
if code == "" {
if !codePattern.MatchString(code) {
c.Redirect(http.StatusFound, "https://hao858.com/")
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)
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
}
@@ -171,10 +172,6 @@ func generateVisitorID() string {
return hex.EncodeToString(b)
}
func urlEscape(s string) string {
return strings.ReplaceAll(s, " ", "%20")
}
func getRequestBaseURL(c *gin.Context) string {
scheme := "https"
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)
type orderStat struct {
TrackCode string `gorm:"column:source_track_code"`
OrderCount int64 `gorm:"column:order_count"`
PaidCount int64 `gorm:"column:paid_order_count"`
PaidGMVCent int64 `gorm:"column:paid_gmv_cent"`
TrackCode string `gorm:"column:source_track_code"`
OrderCount int64 `gorm:"column:order_count"`
PaidCount int64 `gorm:"column:paid_order_count"`
PaidGMVCent int64 `gorm:"column:paid_gmv_cent"`
}
var orderStats []orderStat
_ = r.db.WithContext(ctx).
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).
Group("source_track_code").
Scan(&orderStats).Error
@@ -226,7 +226,7 @@ func (r *Repository) GetOverviewStats(ctx context.Context) (*TrackingOverviewSta
var ot orderTotal
_ = r.db.WithContext(ctx).
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 != ''").
Scan(&ot).Error
stats.TotalOrders = ot.TotalOrders
@@ -257,7 +257,7 @@ func (r *Repository) GetOverviewStats(ctx context.Context) (*TrackingOverviewSta
var todayOt orderTotal
_ = r.db.WithContext(ctx).
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).
Scan(&todayOt).Error
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,
}
func validateTargetURL(rawURL string) (string, error) {
func validateTargetURL(rawURL string, allowLocal bool) (string, error) {
targetURL := strings.TrimSpace(rawURL)
if targetURL == "" {
return "https://hao858.com/", nil
@@ -40,25 +40,37 @@ func validateTargetURL(rawURL string) (string, error) {
targetURL = "https://" + 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("目标落地页地址不合法")
}
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 targetURL, nil
}
type Service struct {
repo *Repository
redis *redis.Client
repo *Repository
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{
repo: repo,
redis: redisClient,
repo: repo,
redis: redisClient,
allowLocalTargets: allowLocalTargets,
}
}
@@ -77,7 +89,7 @@ func (s *Service) CreateLink(ctx context.Context, adminID uint64, req CreateTrac
return nil, ErrCodeAlreadyExists
}
targetURL, err := validateTargetURL(req.TargetURL)
targetURL, err := validateTargetURL(req.TargetURL, s.allowLocalTargets)
if err != nil {
return nil, err
}
@@ -113,7 +125,7 @@ func (s *Service) UpdateLink(ctx context.Context, id uint64, req UpdateTrackingL
return ErrLinkNotFound
}
targetURL, err := validateTargetURL(req.TargetURL)
targetURL, err := validateTargetURL(req.TargetURL, s.allowLocalTargets)
if err != nil {
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)
defer cancel()
// 防刷频控:同一个访客/IP在 5 秒内重复点击相同短链,不重复记录流水
// 防刷:同访客 5 秒去重;同 IP 1 秒去重,避免不带 Cookie 轮换 visitorID 刷库
if s.redis != nil {
dedupID := visitorID
if dedupID == "" {
dedupID = ip
if isDuplicateClick(bgCtx, s.redis, "adtrack:dedup:v:"+link.Code+":"+visitorID, 5*time.Second) {
return
}
if dedupID != "" {
dedupKey := "adtrack:dedup:" + link.Code + ":" + dedupID
ok, rErr := s.redis.SetNX(bgCtx, dedupKey, "1", 5*time.Second).Result()
if rErr == nil && !ok {
return
}
if isDuplicateClick(bgCtx, s.redis, "adtrack:dedup:ip:"+link.Code+":"+ip, time.Second) {
return
}
}
@@ -214,6 +221,14 @@ func (s *Service) ResolveRedirect(ctx context.Context, code, ip, ua, referer, vi
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 {
@@ -92,38 +92,40 @@ func TestCodePattern(t *testing.T) {
func TestValidateTargetURL(t *testing.T) {
tests := []struct {
input string
allowLocal bool
wantURL string
wantErr bool
errExpected error
}{
{"", "https://hao858.com/", false, nil},
{"/listings", "https://hao858.com/listings", false, nil},
{"https://hao858.com", "https://hao858.com", false, nil},
{"https://h5.hao858.com/m", "https://h5.hao858.com/m", false, nil},
{"http://localhost:5189", "http://localhost:5189", false, nil},
{"http://127.0.0.1:18088", "http://127.0.0.1:18088", false, nil},
{"https://evil-phishing.com", "", true, ErrDisallowedTargetDomain},
{"https://hao858.com.fake.com", "", true, ErrDisallowedTargetDomain},
{"https://sub.evil.com/hao858.com", "", true, ErrDisallowedTargetDomain},
{"", false, "https://hao858.com/", false, nil},
{"/listings", false, "https://hao858.com/listings", false, nil},
{"https://hao858.com", false, "https://hao858.com", false, nil},
{"https://h5.hao858.com/m", false, "https://h5.hao858.com/m", false, nil},
{"http://localhost:5189", true, "http://localhost:5189", false, nil},
{"http://127.0.0.1:18088", true, "http://127.0.0.1:18088", false, nil},
{"http://localhost:5189", false, "", true, ErrDisallowedTargetDomain},
{"http://127.0.0.1:18088", false, "", 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 {
got, err := validateTargetURL(tt.input)
got, err := validateTargetURL(tt.input, tt.allowLocal)
if tt.wantErr {
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 {
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 {
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 {
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/handler"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/modules/adtracking"
"hfb_sys/backend/internal/modules/adminaudit"
"hfb_sys/backend/internal/modules/adminauth"
"hfb_sys/backend/internal/modules/admindashboard"
@@ -16,6 +15,7 @@ import (
"hfb_sys/backend/internal/modules/adminpush"
"hfb_sys/backend/internal/modules/adminrole"
"hfb_sys/backend/internal/modules/adminuser"
"hfb_sys/backend/internal/modules/adtracking"
"hfb_sys/backend/internal/modules/announcement"
"hfb_sys/backend/internal/modules/auth"
"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 {
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)
engine.GET("/r/:code", adTrackingHandler.Redirect)
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_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天有效
const TRACK_CODE_PATTERN = /^[a-zA-Z0-9_-]{2,64}$/
interface StoredTrackCode {
code: string
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 {
if (!code) return
const cleanCode = code.trim()
const cleanCode = sanitizeTrackCode(code)
if (!cleanCode) return
try {
const item: StoredTrackCode = {
@@ -31,7 +38,7 @@ export function getTrackCode(): string {
localStorage.removeItem(TRACK_CODE_KEY)
return ''
}
return item.code
return sanitizeTrackCode(item.code)
} catch {
return ''
}