fix(adtracking): 修复生产网关反代路由、成交态统计口径与安全加固
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user