From 6a3611c1a809aa2f01fa32e063dc09a6ddd0f36f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 10 Sep 2026 11:17:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(adtracking):=20=E4=BF=AE=E5=A4=8D=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E7=BD=91=E5=85=B3=E5=8F=8D=E4=BB=A3=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E3=80=81=E6=88=90=E4=BA=A4=E6=80=81=E7=BB=9F=E8=AE=A1=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E4=B8=8E=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/modules/adtracking/handler.go | 6 +- .../internal/modules/adtracking/repository.go | 11 +-- .../internal/modules/adtracking/service.go | 72 ++++++++++++++----- .../modules/adtracking/service_test.go | 39 ++++++++++ backend/internal/modules/auth/repository.go | 15 +++- backend/internal/modules/order/lifecycle.go | 5 +- backend/internal/modules/order/utils.go | 12 ++++ deploy/caddy/Caddyfile | 6 ++ .../components/AssignPermissionsDialog.vue | 1 + frontend/vite.config.ts | 1 + 10 files changed, 140 insertions(+), 28 deletions(-) diff --git a/backend/internal/modules/adtracking/handler.go b/backend/internal/modules/adtracking/handler.go index 31db0a7..d4b5e30 100644 --- a/backend/internal/modules/adtracking/handler.go +++ b/backend/internal/modules/adtracking/handler.go @@ -33,7 +33,9 @@ func (h *Handler) Redirect(c *gin.Context) { visitorID, err := c.Cookie("hfb_v_id") if err != nil || visitorID == "" { visitorID = generateVisitorID() - c.SetCookie("hfb_v_id", visitorID, 31536000, "/", "", false, false) + isSecure := c.Request.TLS != nil || c.Request.Header.Get("X-Forwarded-Proto") == "https" + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie("hfb_v_id", visitorID, 31536000, "/", "", isSecure, true) } ip := c.ClientIP() @@ -195,7 +197,7 @@ func writeAdTrackingError(c *gin.Context, err error) { response.Error(c, http.StatusNotFound, "not_found", err.Error()) case errors.Is(err, ErrCodeAlreadyExists): response.Error(c, http.StatusConflict, "already_exists", err.Error()) - case errors.Is(err, ErrInvalidCodeFormat), errors.Is(err, ErrReservedCode), errors.Is(err, ErrLinkDisabled): + case errors.Is(err, ErrInvalidCodeFormat), errors.Is(err, ErrReservedCode), errors.Is(err, ErrLinkDisabled), errors.Is(err, ErrDisallowedTargetDomain): response.BadRequest(c, err.Error()) default: response.InternalServerError(c, err.Error()) diff --git a/backend/internal/modules/adtracking/repository.go b/backend/internal/modules/adtracking/repository.go index 7c1c17b..2cf29bd 100644 --- a/backend/internal/modules/adtracking/repository.go +++ b/backend/internal/modules/adtracking/repository.go @@ -137,7 +137,7 @@ func (r *Repository) ListLinks(ctx context.Context, query ListTrackingLinksQuery var orderStats []orderStat _ = r.db.WithContext(ctx). Table("rental_orders"). - Select("source_track_code, COUNT(*) as order_count, SUM(CASE WHEN status IN ('paid', 'renting', 'returning', 'returned', 'settled') THEN 1 ELSE 0 END) as paid_order_count, COALESCE(SUM(CASE WHEN status IN ('paid', 'renting', 'returning', 'returned', 'settled') 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') 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"). Where("source_track_code IN (?)", codes). Group("source_track_code"). Scan(&orderStats).Error @@ -226,15 +226,16 @@ 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 IN ('paid', 'renting', 'returning', 'returned', 'settled') THEN 1 ELSE 0 END) as total_paid_orders, COALESCE(SUM(CASE WHEN status IN ('paid', 'renting', 'returning', 'returned', 'settled') 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') 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"). Where("source_track_code IS NOT NULL AND source_track_code != ''"). Scan(&ot).Error stats.TotalOrders = ot.TotalOrders stats.TotalPaidOrders = ot.TotalPaidOrders stats.TotalPaidGMVCent = ot.TotalPaidGMV - // 今日数据计算 - todayStart := time.Now().Truncate(24 * time.Hour) + // 今日数据计算(按本地时区获取今日 00:00:00,避免 UTC 截断产生 8 小时偏差) + now := time.Now() + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) // 今日点击 var todayCt clickTotal @@ -256,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 IN ('paid', 'renting', 'returning', 'returned', 'settled') 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') 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 78d638a..b89decd 100644 --- a/backend/internal/modules/adtracking/service.go +++ b/backend/internal/modules/adtracking/service.go @@ -13,12 +13,13 @@ import ( ) var ( - ErrLinkNotFound = errors.New("推广链接不存在") - ErrLinkDisabled = errors.New("该推广链接已被停用") - ErrCodeAlreadyExists = errors.New("该自定义后缀已被占用,请更换") - ErrInvalidCodeFormat = errors.New("后缀格式不正确,仅支持2-64位英文、数字、下划线和连字符") - ErrReservedCode = errors.New("该后缀为系统保留字,不可使用") - codePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{2,64}$`) + 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{ @@ -27,6 +28,28 @@ var reservedCodes = map[string]bool{ "pay": true, "notice": true, "chat": true, "dashboard": true, } +func validateTargetURL(rawURL string) (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 { + return "", errors.New("目标落地页地址不合法") + } + host := strings.ToLower(u.Hostname()) + if host != "hao858.com" && !strings.HasSuffix(host, ".hao858.com") && host != "localhost" && host != "127.0.0.1" { + return "", ErrDisallowedTargetDomain + } + return targetURL, nil +} + type Service struct { repo *Repository redis *redis.Client @@ -54,11 +77,9 @@ func (s *Service) CreateLink(ctx context.Context, adminID uint64, req CreateTrac return nil, ErrCodeAlreadyExists } - targetURL := strings.TrimSpace(req.TargetURL) - if targetURL == "" { - targetURL = "https://hao858.com/" - } else if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") { - targetURL = "https://" + targetURL + targetURL, err := validateTargetURL(req.TargetURL) + if err != nil { + return nil, err } channelType := strings.TrimSpace(req.ChannelType) @@ -92,11 +113,9 @@ func (s *Service) UpdateLink(ctx context.Context, id uint64, req UpdateTrackingL return ErrLinkNotFound } - targetURL := strings.TrimSpace(req.TargetURL) - if targetURL == "" { - targetURL = "https://hao858.com/" - } else if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") { - targetURL = "https://" + targetURL + targetURL, err := validateTargetURL(req.TargetURL) + if err != nil { + return err } channelType := strings.TrimSpace(req.ChannelType) @@ -151,9 +170,30 @@ func (s *Service) ResolveRedirect(ctx context.Context, code, ip, ua, referer, vi // 异步记录点击流水,防止写库阻塞跳转 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() + // 防刷频控:同一个访客/IP在 5 秒内重复点击相同短链,不重复记录流水 + if s.redis != nil { + dedupID := visitorID + if dedupID == "" { + dedupID = ip + } + 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 + } + } + } + click := &AdTrackingClick{ LinkID: link.ID, Code: link.Code, diff --git a/backend/internal/modules/adtracking/service_test.go b/backend/internal/modules/adtracking/service_test.go index 5675e63..7d101e3 100644 --- a/backend/internal/modules/adtracking/service_test.go +++ b/backend/internal/modules/adtracking/service_test.go @@ -88,3 +88,42 @@ func TestCodePattern(t *testing.T) { } } } + +func TestValidateTargetURL(t *testing.T) { + tests := []struct { + input string + 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}, + } + + for _, tt := range tests { + got, err := validateTargetURL(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("validateTargetURL(%q) expected error, got nil", tt.input) + } + if tt.errExpected != nil && err != tt.errExpected { + t.Errorf("validateTargetURL(%q) err = %v; want %v", tt.input, err, tt.errExpected) + } + } else { + if err != nil { + t.Errorf("validateTargetURL(%q) unexpected error: %v", tt.input, err) + } + if got != tt.wantURL { + t.Errorf("validateTargetURL(%q) = %q; want %q", tt.input, got, tt.wantURL) + } + } + } +} + diff --git a/backend/internal/modules/auth/repository.go b/backend/internal/modules/auth/repository.go index 5a9f8bc..43dd279 100644 --- a/backend/internal/modules/auth/repository.go +++ b/backend/internal/modules/auth/repository.go @@ -3,6 +3,7 @@ package auth import ( "context" "errors" + "regexp" "strings" "time" @@ -12,6 +13,16 @@ import ( "gorm.io/gorm/clause" ) +var trackCodePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) + +func sanitizeTrackCode(code string) string { + s := strings.TrimSpace(code) + if len(s) == 0 || len(s) > 64 || !trackCodePattern.MatchString(s) { + return "" + } + return s +} + type UserRepository struct { db *gorm.DB } @@ -62,7 +73,7 @@ func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string, now := time.Now() var code string if len(trackCode) > 0 { - code = strings.TrimSpace(trackCode[0]) + code = sanitizeTrackCode(trackCode[0]) } user := model.User{ Phone: phone, @@ -106,7 +117,7 @@ func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, now := time.Now() var code string if len(trackCode) > 0 { - code = strings.TrimSpace(trackCode[0]) + code = sanitizeTrackCode(trackCode[0]) } user := model.User{ Phone: phone, diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 8a9cec8..1f2d234 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -3,7 +3,6 @@ package order import ( "context" "encoding/json" - "strings" "time" "hfb_sys/backend/internal/model" @@ -71,11 +70,11 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ if err != nil { return err } - sourceTrackCode := strings.TrimSpace(req.TrackCode) + sourceTrackCode := sanitizeTrackCode(req.TrackCode) if sourceTrackCode == "" { var u model.User if err := tx.Select("register_track_code").First(&u, renterID).Error; err == nil && u.RegisterTrackCode != "" { - sourceTrackCode = u.RegisterTrackCode + sourceTrackCode = sanitizeTrackCode(u.RegisterTrackCode) } } order := model.RentalOrder{ diff --git a/backend/internal/modules/order/utils.go b/backend/internal/modules/order/utils.go index 7ba7d2f..13dc82d 100644 --- a/backend/internal/modules/order/utils.go +++ b/backend/internal/modules/order/utils.go @@ -3,13 +3,25 @@ package order import ( "crypto/rand" "errors" + "regexp" "strconv" + "strings" "gorm.io/gorm" "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/timeutil" ) +var trackCodePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) + +func sanitizeTrackCode(code string) string { + s := strings.TrimSpace(code) + if len(s) == 0 || len(s) > 64 || !trackCodePattern.MatchString(s) { + return "" + } + return s +} + func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 1a8d3b4..0957a82 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -30,6 +30,12 @@ } } + handle /r/* { + reverse_proxy backend:8080 { + import backend_proxy + } + } + @health path /health /health/ready handle @health { reverse_proxy backend:8080 { diff --git a/frontend/src/features/admin/components/AssignPermissionsDialog.vue b/frontend/src/features/admin/components/AssignPermissionsDialog.vue index b8726f9..a0d0965 100644 --- a/frontend/src/features/admin/components/AssignPermissionsDialog.vue +++ b/frontend/src/features/admin/components/AssignPermissionsDialog.vue @@ -112,6 +112,7 @@ const resourceLabels: Record = { role: '角色管理', system_config: '系统配置', audit_log: '审计日志', + ad_tracking: '推广追踪', } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b82c12b..801a44a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -82,6 +82,7 @@ export default defineConfig({ strictPort: true, proxy: { '/api': apiProxyTarget, + '/r': apiProxyTarget, }, allowedHosts: [ '221329.cc.cd',