fix(adtracking): 修复生产网关反代路由、成交态统计口径与安全加固

This commit is contained in:
yml2213
2026-09-10 11:17:40 +08:00
parent 2b11d70ebe
commit 6a3611c1a8
10 changed files with 140 additions and 28 deletions
@@ -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())
@@ -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
+56 -16
View File
@@ -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,
@@ -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)
}
}
}
}