fix(adtracking): 收紧成交口径、点击去重与生产落地页白名单
成交 GMV 排除已关闭订单,避免未支付关单被计入;点击按访客 5 秒、IP 1 秒双重去重;生产禁止 localhost 落地页,失效短码改用 QueryEscape。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user