diff --git a/backend/internal/modules/adtracking/handler.go b/backend/internal/modules/adtracking/handler.go index d4b5e30..67d3527 100644 --- a/backend/internal/modules/adtracking/handler.go +++ b/backend/internal/modules/adtracking/handler.go @@ -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 != "" { diff --git a/backend/internal/modules/adtracking/repository.go b/backend/internal/modules/adtracking/repository.go index 2cf29bd..03ba0fe 100644 --- a/backend/internal/modules/adtracking/repository.go +++ b/backend/internal/modules/adtracking/repository.go @@ -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 diff --git a/backend/internal/modules/adtracking/service.go b/backend/internal/modules/adtracking/service.go index b89decd..40c8bc8 100644 --- a/backend/internal/modules/adtracking/service.go +++ b/backend/internal/modules/adtracking/service.go @@ -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 { diff --git a/backend/internal/modules/adtracking/service_test.go b/backend/internal/modules/adtracking/service_test.go index 7d101e3..40893e0 100644 --- a/backend/internal/modules/adtracking/service_test.go +++ b/backend/internal/modules/adtracking/service_test.go @@ -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) } } } } - diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 2701434..49f482a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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 { diff --git a/frontend/src/shared/utils/trackCode.ts b/frontend/src/shared/utils/trackCode.ts index 61769dc..1ffa819 100644 --- a/frontend/src/shared/utils/trackCode.ts +++ b/frontend/src/shared/utils/trackCode.ts @@ -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 '' }