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") visitorID, err := c.Cookie("hfb_v_id")
if err != nil || visitorID == "" { if err != nil || visitorID == "" {
visitorID = generateVisitorID() 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() ip := c.ClientIP()
@@ -195,7 +197,7 @@ func writeAdTrackingError(c *gin.Context, err error) {
response.Error(c, http.StatusNotFound, "not_found", err.Error()) response.Error(c, http.StatusNotFound, "not_found", err.Error())
case errors.Is(err, ErrCodeAlreadyExists): case errors.Is(err, ErrCodeAlreadyExists):
response.Error(c, http.StatusConflict, "already_exists", err.Error()) 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()) response.BadRequest(c, err.Error())
default: default:
response.InternalServerError(c, err.Error()) response.InternalServerError(c, err.Error())
@@ -137,7 +137,7 @@ func (r *Repository) ListLinks(ctx context.Context, query ListTrackingLinksQuery
var orderStats []orderStat var orderStats []orderStat
_ = r.db.WithContext(ctx). _ = r.db.WithContext(ctx).
Table("rental_orders"). 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). Where("source_track_code IN (?)", codes).
Group("source_track_code"). Group("source_track_code").
Scan(&orderStats).Error Scan(&orderStats).Error
@@ -226,15 +226,16 @@ func (r *Repository) GetOverviewStats(ctx context.Context) (*TrackingOverviewSta
var ot orderTotal var ot orderTotal
_ = r.db.WithContext(ctx). _ = r.db.WithContext(ctx).
Table("rental_orders"). 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 != ''"). Where("source_track_code IS NOT NULL AND source_track_code != ''").
Scan(&ot).Error Scan(&ot).Error
stats.TotalOrders = ot.TotalOrders stats.TotalOrders = ot.TotalOrders
stats.TotalPaidOrders = ot.TotalPaidOrders stats.TotalPaidOrders = ot.TotalPaidOrders
stats.TotalPaidGMVCent = ot.TotalPaidGMV stats.TotalPaidGMVCent = ot.TotalPaidGMV
// 今日数据计算 // 今日数据计算(按本地时区获取今日 00:00:00,避免 UTC 截断产生 8 小时偏差)
todayStart := time.Now().Truncate(24 * time.Hour) now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
// 今日点击 // 今日点击
var todayCt clickTotal var todayCt clickTotal
@@ -256,7 +257,7 @@ func (r *Repository) GetOverviewStats(ctx context.Context) (*TrackingOverviewSta
var todayOt orderTotal var todayOt orderTotal
_ = r.db.WithContext(ctx). _ = r.db.WithContext(ctx).
Table("rental_orders"). 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). Where("source_track_code IS NOT NULL AND source_track_code != '' AND created_at >= ?", todayStart).
Scan(&todayOt).Error Scan(&todayOt).Error
stats.TodayOrders = todayOt.TotalOrders stats.TodayOrders = todayOt.TotalOrders
+56 -16
View File
@@ -13,12 +13,13 @@ import (
) )
var ( var (
ErrLinkNotFound = errors.New("推广链接不存在") ErrLinkNotFound = errors.New("推广链接不存在")
ErrLinkDisabled = errors.New("该推广链接已被停用") ErrLinkDisabled = errors.New("该推广链接已被停用")
ErrCodeAlreadyExists = errors.New("该自定义后缀已被占用,请更换") ErrCodeAlreadyExists = errors.New("该自定义后缀已被占用,请更换")
ErrInvalidCodeFormat = errors.New("后缀格式不正确,仅支持2-64位英文、数字、下划线和连字符") ErrInvalidCodeFormat = errors.New("后缀格式不正确,仅支持2-64位英文、数字、下划线和连字符")
ErrReservedCode = errors.New("该后缀为系统保留字,不可使用") ErrReservedCode = errors.New("该后缀为系统保留字,不可使用")
codePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{2,64}$`) ErrDisallowedTargetDomain = errors.New("目标落地页仅允许为 hao858.com 及其子域名或站内路径")
codePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{2,64}$`)
) )
var reservedCodes = map[string]bool{ var reservedCodes = map[string]bool{
@@ -27,6 +28,28 @@ var reservedCodes = map[string]bool{
"pay": true, "notice": true, "chat": true, "dashboard": true, "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 { type Service struct {
repo *Repository repo *Repository
redis *redis.Client redis *redis.Client
@@ -54,11 +77,9 @@ func (s *Service) CreateLink(ctx context.Context, adminID uint64, req CreateTrac
return nil, ErrCodeAlreadyExists return nil, ErrCodeAlreadyExists
} }
targetURL := strings.TrimSpace(req.TargetURL) targetURL, err := validateTargetURL(req.TargetURL)
if targetURL == "" { if err != nil {
targetURL = "https://hao858.com/" return nil, err
} else if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") {
targetURL = "https://" + targetURL
} }
channelType := strings.TrimSpace(req.ChannelType) channelType := strings.TrimSpace(req.ChannelType)
@@ -92,11 +113,9 @@ func (s *Service) UpdateLink(ctx context.Context, id uint64, req UpdateTrackingL
return ErrLinkNotFound return ErrLinkNotFound
} }
targetURL := strings.TrimSpace(req.TargetURL) targetURL, err := validateTargetURL(req.TargetURL)
if targetURL == "" { if err != nil {
targetURL = "https://hao858.com/" return err
} else if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") {
targetURL = "https://" + targetURL
} }
channelType := strings.TrimSpace(req.ChannelType) 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) deviceType, os, browser := parseUserAgent(ua)
go func() { go func() {
defer func() {
if r := recover(); r != nil {
// 保护主进程,防止异步协程 panic
}
}()
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() 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{ click := &AdTrackingClick{
LinkID: link.ID, LinkID: link.ID,
Code: link.Code, 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)
}
}
}
}
+13 -2
View File
@@ -3,6 +3,7 @@ package auth
import ( import (
"context" "context"
"errors" "errors"
"regexp"
"strings" "strings"
"time" "time"
@@ -12,6 +13,16 @@ import (
"gorm.io/gorm/clause" "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 { type UserRepository struct {
db *gorm.DB db *gorm.DB
} }
@@ -62,7 +73,7 @@ func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string,
now := time.Now() now := time.Now()
var code string var code string
if len(trackCode) > 0 { if len(trackCode) > 0 {
code = strings.TrimSpace(trackCode[0]) code = sanitizeTrackCode(trackCode[0])
} }
user := model.User{ user := model.User{
Phone: phone, Phone: phone,
@@ -106,7 +117,7 @@ func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string,
now := time.Now() now := time.Now()
var code string var code string
if len(trackCode) > 0 { if len(trackCode) > 0 {
code = strings.TrimSpace(trackCode[0]) code = sanitizeTrackCode(trackCode[0])
} }
user := model.User{ user := model.User{
Phone: phone, Phone: phone,
+2 -3
View File
@@ -3,7 +3,6 @@ package order
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"strings"
"time" "time"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
@@ -71,11 +70,11 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
if err != nil { if err != nil {
return err return err
} }
sourceTrackCode := strings.TrimSpace(req.TrackCode) sourceTrackCode := sanitizeTrackCode(req.TrackCode)
if sourceTrackCode == "" { if sourceTrackCode == "" {
var u model.User var u model.User
if err := tx.Select("register_track_code").First(&u, renterID).Error; err == nil && u.RegisterTrackCode != "" { 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{ order := model.RentalOrder{
+12
View File
@@ -3,13 +3,25 @@ package order
import ( import (
"crypto/rand" "crypto/rand"
"errors" "errors"
"regexp"
"strconv" "strconv"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
"hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/timeutil" "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 { func firstNonEmpty(values ...string) string {
for _, value := range values { for _, value := range values {
if value != "" { if value != "" {
+6
View File
@@ -30,6 +30,12 @@
} }
} }
handle /r/* {
reverse_proxy backend:8080 {
import backend_proxy
}
}
@health path /health /health/ready @health path /health /health/ready
handle @health { handle @health {
reverse_proxy backend:8080 { reverse_proxy backend:8080 {
@@ -112,6 +112,7 @@ const resourceLabels: Record<string, string> = {
role: '角色管理', role: '角色管理',
system_config: '系统配置', system_config: '系统配置',
audit_log: '审计日志', audit_log: '审计日志',
ad_tracking: '推广追踪',
} }
</script> </script>
+1
View File
@@ -82,6 +82,7 @@ export default defineConfig({
strictPort: true, strictPort: true,
proxy: { proxy: {
'/api': apiProxyTarget, '/api': apiProxyTarget,
'/r': apiProxyTarget,
}, },
allowedHosts: [ allowedHosts: [
'221329.cc.cd', '221329.cc.cd',