From 2b11d70ebe1f81a471ae2438c7272599c5462fe9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 10 Sep 2026 11:05:47 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(adtracking):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=8E=A8=E5=B9=BF=E9=93=BE=E6=8E=A5=E8=BF=BD=E8=B8=AA=E4=B8=8E?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=A4=A7=E7=9B=98=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E7=9F=AD=E7=A0=81=E3=80=81=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E4=B8=8E=E4=B8=8B=E5=8D=95=E5=BD=92=E5=9B=A0=E5=8F=8A?= =?UTF-8?q?=E4=BA=8C=E7=BB=B4=E7=A0=81=E7=89=A9=E6=96=99=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 数据库新增 000064_ad_tracking.sql 迁移,创建 ad_tracking_links 和 ad_tracking_clicks 表,并在 users、rental_orders 增加归因字段 2. 后端新增 adtracking 模块,挂载 GET /r/:code 302 智能重定向、点击流水异步记录、转化率/GMV聚合统计及管理接口 3. 用户认证 (auth) 与订单创建 (order) 链路贯通,实现注册用户与订单成交推广短码全链路归因 4. 前端管理后台左侧新增【推广追踪】目录,支持大盘指标总览、推广链接 CRUD、状态启停、一键复制短链及二维码下载 (PNG) --- .gitignore | 3 +- backend/internal/model/order.go | 1 + backend/internal/model/user.go | 1 + backend/internal/modules/adtracking/dto.go | 75 ++ .../internal/modules/adtracking/handler.go | 203 ++++ backend/internal/modules/adtracking/model.go | 41 + .../internal/modules/adtracking/repository.go | 266 +++++ .../internal/modules/adtracking/service.go | 234 ++++ .../modules/adtracking/service_test.go | 90 ++ backend/internal/modules/auth/handler.go | 16 +- backend/internal/modules/auth/repository.go | 45 +- backend/internal/modules/auth/service.go | 8 +- backend/internal/modules/order/dto.go | 2 + backend/internal/modules/order/lifecycle.go | 9 + backend/internal/modules/order/presenter.go | 1 + backend/internal/router/router.go | 15 + backend/migrations/000064_ad_tracking.sql | 73 ++ frontend/src/features/admin/api/adTracking.ts | 110 ++ .../admin/views/AdminTrackingLinksView.vue | 1048 +++++++++++++++++ frontend/src/features/auth/api/auth.ts | 19 +- frontend/src/features/orders/api/orders.ts | 8 +- frontend/src/layouts/AdminLayout.vue | 15 + frontend/src/router/adminRoutes.ts | 6 + frontend/src/router/index.ts | 5 + frontend/src/shared/utils/trackCode.ts | 46 + 25 files changed, 2304 insertions(+), 36 deletions(-) create mode 100644 backend/internal/modules/adtracking/dto.go create mode 100644 backend/internal/modules/adtracking/handler.go create mode 100644 backend/internal/modules/adtracking/model.go create mode 100644 backend/internal/modules/adtracking/repository.go create mode 100644 backend/internal/modules/adtracking/service.go create mode 100644 backend/internal/modules/adtracking/service_test.go create mode 100644 backend/migrations/000064_ad_tracking.sql create mode 100644 frontend/src/features/admin/api/adTracking.ts create mode 100644 frontend/src/features/admin/views/AdminTrackingLinksView.vue create mode 100644 frontend/src/shared/utils/trackCode.ts diff --git a/.gitignore b/.gitignore index 3f3e24c..80d7bd7 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,5 @@ logs artifacts/ frontend/coverage/ -.zcode/ \ No newline at end of file +.zcode/ +.workbuddy/ \ No newline at end of file diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index 3150cd6..e0e3d03 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -66,6 +66,7 @@ type RentalOrder struct { DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at"` OwnerSettledAt *time.Time `json:"owner_settled_at"` SettledAt *time.Time `json:"settled_at"` + SourceTrackCode string `gorm:"size:64;index:idx_rental_orders_source_track_code" json:"source_track_code,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 6610291..340e29b 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -16,6 +16,7 @@ type User struct { RenterGrowthLevel string `gorm:"size:32;not null;default:'normal';index:idx_users_renter_growth_level,priority:1" json:"renter_growth_level"` Status string `gorm:"size:32;not null;default:'active'" json:"status"` TokenVersion int64 `gorm:"not null;default:0" json:"-"` + RegisterTrackCode string `gorm:"size:64;index:idx_users_register_track_code" json:"register_track_code,omitempty"` LastLoginAt *time.Time `json:"last_login_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/backend/internal/modules/adtracking/dto.go b/backend/internal/modules/adtracking/dto.go new file mode 100644 index 0000000..e5ef21f --- /dev/null +++ b/backend/internal/modules/adtracking/dto.go @@ -0,0 +1,75 @@ +package adtracking + +import "time" + +type TrackingLinkItem struct { + ID uint64 `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + ChannelType string `json:"channel_type"` + TargetURL string `json:"target_url"` + ShortURL string `json:"short_url"` + Status string `json:"status"` + CostCent int64 `json:"cost_cent"` + Notes string `json:"notes"` + CreatedBy uint64 `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // 统计指标 + ClickPV int64 `json:"click_pv"` + ClickUV int64 `json:"click_uv"` + ClickIP int64 `json:"click_ip"` + RegisterCount int64 `json:"register_count"` + OrderCount int64 `json:"order_count"` + PaidOrderCount int64 `json:"paid_order_count"` + PaidGMVCent int64 `json:"paid_gmv_cent"` + ConversionRate float64 `json:"conversion_rate"` // 下单转化率 (OrderCount / max(ClickUV, 1)) +} + +type CreateTrackingLinkRequest struct { + Name string `json:"name" binding:"required,min=1,max=128"` + Code string `json:"code" binding:"required,min=2,max=64"` + ChannelType string `json:"channel_type"` + TargetURL string `json:"target_url"` + CostCent int64 `json:"cost_cent"` + Notes string `json:"notes" binding:"max=500"` +} + +type UpdateTrackingLinkRequest struct { + Name string `json:"name" binding:"required,min=1,max=128"` + ChannelType string `json:"channel_type"` + TargetURL string `json:"target_url"` + CostCent int64 `json:"cost_cent"` + Notes string `json:"notes" binding:"max=500"` +} + +type UpdateLinkStatusRequest struct { + Status string `json:"status" binding:"required,oneof=active disabled"` +} + +type ListTrackingLinksQuery struct { + Keyword string `form:"keyword"` + ChannelType string `form:"channel_type"` + Status string `form:"status"` + Page int `form:"page,default=1"` + PageSize int `form:"page_size,default=20"` +} + +type TrackingOverviewStats struct { + TotalLinks int64 `json:"total_links"` + ActiveLinks int64 `json:"active_links"` + TotalPV int64 `json:"total_pv"` + TotalUV int64 `json:"total_uv"` + TotalIP int64 `json:"total_ip"` + TotalRegisters int64 `json:"total_registers"` + TotalOrders int64 `json:"total_orders"` + TotalPaidOrders int64 `json:"total_paid_orders"` + TotalPaidGMVCent int64 `json:"total_paid_gmv_cent"` + + TodayPV int64 `json:"today_pv"` + TodayUV int64 `json:"today_uv"` + TodayRegisters int64 `json:"today_registers"` + TodayOrders int64 `json:"today_orders"` + TodayPaidGMVCent int64 `json:"today_paid_gmv_cent"` +} diff --git a/backend/internal/modules/adtracking/handler.go b/backend/internal/modules/adtracking/handler.go new file mode 100644 index 0000000..31db0a7 --- /dev/null +++ b/backend/internal/modules/adtracking/handler.go @@ -0,0 +1,203 @@ +package adtracking + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "net/http" + "strconv" + "strings" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +// Redirect 公开短链 302 智能重定向 +func (h *Handler) Redirect(c *gin.Context) { + code := strings.TrimSpace(c.Param("code")) + if code == "" { + c.Redirect(http.StatusFound, "https://hao858.com/") + return + } + + visitorID, err := c.Cookie("hfb_v_id") + if err != nil || visitorID == "" { + visitorID = generateVisitorID() + c.SetCookie("hfb_v_id", visitorID, 31536000, "/", "", false, false) + } + + ip := c.ClientIP() + ua := c.Request.UserAgent() + referer := c.Request.Referer() + + 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)) + return + } + + c.Redirect(http.StatusFound, targetURL) +} + +// List 推广链接列表查询(带多维度汇总数据) +func (h *Handler) List(c *gin.Context) { + var query ListTrackingLinksQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "查询参数有误") + return + } + + baseURL := getRequestBaseURL(c) + items, total, err := h.service.ListLinks(c.Request.Context(), query, baseURL) + if err != nil { + response.InternalServerError(c, "获取推广链接列表失败") + return + } + + response.OK(c, gin.H{ + "items": items, + "total": total, + }) +} + +// Overview 推广大盘统计汇总 +func (h *Handler) Overview(c *gin.Context) { + stats, err := h.service.GetOverview(c.Request.Context()) + if err != nil { + response.InternalServerError(c, "获取推广统计数据失败") + return + } + response.OK(c, stats) +} + +// Create 创建推广链接 +func (h *Handler) Create(c *gin.Context) { + var req CreateTrackingLinkRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求参数不正确: "+err.Error()) + return + } + + adminID, _ := currentAdminID(c) + item, err := h.service.CreateLink(c.Request.Context(), adminID, req) + if err != nil { + writeAdTrackingError(c, err) + return + } + + response.Created(c, item) +} + +// Update 更新推广链接 +func (h *Handler) Update(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + + var req UpdateTrackingLinkRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求参数不正确: "+err.Error()) + return + } + + if err := h.service.UpdateLink(c.Request.Context(), id, req); err != nil { + writeAdTrackingError(c, err) + return + } + + response.OK(c, gin.H{"updated": true}) +} + +// UpdateStatus 启停推广链接 +func (h *Handler) UpdateStatus(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + + var req UpdateLinkStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "状态参数不合法") + return + } + + if err := h.service.UpdateStatus(c.Request.Context(), id, req.Status); err != nil { + writeAdTrackingError(c, err) + return + } + + response.OK(c, gin.H{"updated": true}) +} + +func parseID(c *gin.Context) (uint64, bool) { + val := c.Param("id") + id, err := strconv.ParseUint(val, 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "无效的ID") + return 0, false + } + return id, true +} + +func currentAdminID(c *gin.Context) (uint64, bool) { + val, exists := c.Get(middleware.ContextAdminID) + if !exists { + return 0, false + } + id, ok := val.(uint64) + if !ok { + return 0, false + } + return id, true +} + +func generateVisitorID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + 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 != "" { + scheme = proto + } else if c.Request.TLS == nil { + scheme = "http" + } + host := c.Request.Host + if host == "" { + return "https://hao858.com" + } + // 如果是本地开发环境端口,保留域名 + return scheme + "://" + host +} + +func writeAdTrackingError(c *gin.Context, err error) { + response.RecordError(c, err) + switch { + case errors.Is(err, ErrLinkNotFound): + 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): + response.BadRequest(c, err.Error()) + default: + response.InternalServerError(c, err.Error()) + } +} diff --git a/backend/internal/modules/adtracking/model.go b/backend/internal/modules/adtracking/model.go new file mode 100644 index 0000000..1ac8544 --- /dev/null +++ b/backend/internal/modules/adtracking/model.go @@ -0,0 +1,41 @@ +package adtracking + +import ( + "time" +) + +type AdTrackingLink struct { + ID uint64 `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:128;not null" json:"name"` + Code string `gorm:"size:64;not null;uniqueIndex:uk_ad_tracking_links_code" json:"code"` + ChannelType string `gorm:"size:64;not null;default:'other';index:idx_ad_tracking_links_channel" json:"channel_type"` + TargetURL string `gorm:"size:512;not null;default:'https://hao858.com/'" json:"target_url"` + Status string `gorm:"size:16;not null;default:'active';index:idx_ad_tracking_links_status" json:"status"` + CostCent int64 `gorm:"not null;default:0" json:"cost_cent"` + Notes string `gorm:"size:500;not null;default:''" json:"notes"` + CreatedBy uint64 `gorm:"not null;default:0" json:"created_by"` + CreatedAt time.Time `gorm:"index:idx_ad_tracking_links_created" json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (AdTrackingLink) TableName() string { + return "ad_tracking_links" +} + +type AdTrackingClick struct { + ID uint64 `gorm:"primaryKey" json:"id"` + LinkID uint64 `gorm:"not null;index:idx_ad_tracking_clicks_link_created,priority:1" json:"link_id"` + Code string `gorm:"size:64;not null;index:idx_ad_tracking_clicks_code_created,priority:1" json:"code"` + IP string `gorm:"size:64;not null;default:'';index:idx_ad_tracking_clicks_ip_created,priority:1" json:"ip"` + UserAgent string `gorm:"size:512;not null;default:''" json:"user_agent"` + DeviceType string `gorm:"size:32;not null;default:'unknown'" json:"device_type"` + OS string `gorm:"size:32;not null;default:'unknown'" json:"os"` + Browser string `gorm:"size:32;not null;default:'unknown'" json:"browser"` + Referer string `gorm:"size:512;not null;default:''" json:"referer"` + VisitorID string `gorm:"size:64;not null;default:'';index:idx_ad_tracking_clicks_visitor,priority:1" json:"visitor_id"` + CreatedAt time.Time `gorm:"index:idx_ad_tracking_clicks_link_created,priority:2;index:idx_ad_tracking_clicks_code_created,priority:2;index:idx_ad_tracking_clicks_ip_created,priority:2;index:idx_ad_tracking_clicks_visitor,priority:2" json:"created_at"` +} + +func (AdTrackingClick) TableName() string { + return "ad_tracking_clicks" +} diff --git a/backend/internal/modules/adtracking/repository.go b/backend/internal/modules/adtracking/repository.go new file mode 100644 index 0000000..7c1c17b --- /dev/null +++ b/backend/internal/modules/adtracking/repository.go @@ -0,0 +1,266 @@ +package adtracking + +import ( + "context" + "strings" + "time" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) CreateLink(ctx context.Context, link *AdTrackingLink) error { + return r.db.WithContext(ctx).Create(link).Error +} + +func (r *Repository) UpdateLink(ctx context.Context, id uint64, updates map[string]interface{}) error { + return r.db.WithContext(ctx).Model(&AdTrackingLink{}).Where("id = ?", id).Updates(updates).Error +} + +func (r *Repository) FindLinkByID(ctx context.Context, id uint64) (*AdTrackingLink, error) { + var link AdTrackingLink + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&link).Error; err != nil { + return nil, err + } + return &link, nil +} + +func (r *Repository) FindLinkByCode(ctx context.Context, code string) (*AdTrackingLink, error) { + var link AdTrackingLink + if err := r.db.WithContext(ctx).Where("code = ?", code).First(&link).Error; err != nil { + return nil, err + } + return &link, nil +} + +func (r *Repository) RecordClick(ctx context.Context, click *AdTrackingClick) error { + return r.db.WithContext(ctx).Create(click).Error +} + +func (r *Repository) ListLinks(ctx context.Context, query ListTrackingLinksQuery, baseURL string) ([]TrackingLinkItem, int64, error) { + tx := r.db.WithContext(ctx).Model(&AdTrackingLink{}) + + if kw := strings.TrimSpace(query.Keyword); kw != "" { + like := "%" + kw + "%" + tx = tx.Where("name LIKE ? OR code LIKE ? OR notes LIKE ?", like, like, like) + } + if ct := strings.TrimSpace(query.ChannelType); ct != "" && ct != "all" { + tx = tx.Where("channel_type = ?", ct) + } + if st := strings.TrimSpace(query.Status); st != "" && st != "all" { + tx = tx.Where("status = ?", st) + } + + var total int64 + if err := tx.Count(&total).Error; err != nil { + return nil, 0, err + } + + page := query.Page + if page < 1 { + page = 1 + } + pageSize := query.PageSize + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + offset := (page - 1) * pageSize + + var links []AdTrackingLink + if err := tx.Order("id DESC").Offset(offset).Limit(pageSize).Find(&links).Error; err != nil { + return nil, 0, err + } + + if len(links) == 0 { + return []TrackingLinkItem{}, total, nil + } + + linkIDs := make([]uint64, 0, len(links)) + codes := make([]string, 0, len(links)) + for _, l := range links { + linkIDs = append(linkIDs, l.ID) + codes = append(codes, l.Code) + } + + // 1. 批量聚合点击数据 (PV, UV, IP) + type clickStat struct { + LinkID uint64 `gorm:"column:link_id"` + PV int64 `gorm:"column:pv"` + UV int64 `gorm:"column:uv"` + IP int64 `gorm:"column:ips"` + } + var clickStats []clickStat + _ = r.db.WithContext(ctx). + Table("ad_tracking_clicks"). + Select("link_id, COUNT(*) as pv, COUNT(DISTINCT visitor_id) as uv, COUNT(DISTINCT ip) as ips"). + Where("link_id IN (?)", linkIDs). + Group("link_id"). + Scan(&clickStats).Error + + clickStatMap := make(map[uint64]clickStat, len(clickStats)) + for _, cs := range clickStats { + clickStatMap[cs.LinkID] = cs + } + + // 2. 批量聚合注册人数 + type userStat struct { + TrackCode string `gorm:"column:register_track_code"` + Count int64 `gorm:"column:reg_count"` + } + var userStats []userStat + _ = r.db.WithContext(ctx). + Table("users"). + Select("register_track_code, COUNT(*) as reg_count"). + Where("register_track_code IN (?)", codes). + Group("register_track_code"). + Scan(&userStats).Error + + userStatMap := make(map[string]int64, len(userStats)) + for _, us := range userStats { + userStatMap[us.TrackCode] = us.Count + } + + // 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"` + } + 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"). + Where("source_track_code IN (?)", codes). + Group("source_track_code"). + Scan(&orderStats).Error + + orderStatMap := make(map[string]orderStat, len(orderStats)) + for _, os := range orderStats { + orderStatMap[os.TrackCode] = os + } + + // 组装最终结果 + items := make([]TrackingLinkItem, 0, len(links)) + cleanBaseURL := strings.TrimRight(baseURL, "/") + for _, l := range links { + cs := clickStatMap[l.ID] + regCount := userStatMap[l.Code] + os := orderStatMap[l.Code] + + var convRate float64 + if cs.UV > 0 { + convRate = float64(os.OrderCount) / float64(cs.UV) * 100.0 + } + + shortURL := cleanBaseURL + "/r/" + l.Code + + items = append(items, TrackingLinkItem{ + ID: l.ID, + Name: l.Name, + Code: l.Code, + ChannelType: l.ChannelType, + TargetURL: l.TargetURL, + ShortURL: shortURL, + Status: l.Status, + CostCent: l.CostCent, + Notes: l.Notes, + CreatedBy: l.CreatedBy, + CreatedAt: l.CreatedAt, + UpdatedAt: l.UpdatedAt, + ClickPV: cs.PV, + ClickUV: cs.UV, + ClickIP: cs.IP, + RegisterCount: regCount, + OrderCount: os.OrderCount, + PaidOrderCount: os.PaidCount, + PaidGMVCent: os.PaidGMVCent, + ConversionRate: convRate, + }) + } + + return items, total, nil +} + +func (r *Repository) GetOverviewStats(ctx context.Context) (*TrackingOverviewStats, error) { + stats := &TrackingOverviewStats{} + + // 链接总数与启用数 + _ = r.db.WithContext(ctx).Model(&AdTrackingLink{}).Count(&stats.TotalLinks).Error + _ = r.db.WithContext(ctx).Model(&AdTrackingLink{}).Where("status = 'active'").Count(&stats.ActiveLinks).Error + + // 累计点击 + type clickTotal struct { + PV int64 `gorm:"column:pv"` + UV int64 `gorm:"column:uv"` + IPs int64 `gorm:"column:ips"` + } + var ct clickTotal + _ = r.db.WithContext(ctx). + Table("ad_tracking_clicks"). + Select("COUNT(*) as pv, COUNT(DISTINCT visitor_id) as uv, COUNT(DISTINCT ip) as ips"). + Scan(&ct).Error + stats.TotalPV = ct.PV + stats.TotalUV = ct.UV + stats.TotalIP = ct.IPs + + // 累计注册 + _ = r.db.WithContext(ctx). + Table("users"). + Where("register_track_code IS NOT NULL AND register_track_code != ''"). + Count(&stats.TotalRegisters).Error + + // 累计订单与GMV + type orderTotal struct { + TotalOrders int64 `gorm:"column:total_orders"` + TotalPaidOrders int64 `gorm:"column:total_paid_orders"` + TotalPaidGMV int64 `gorm:"column:total_paid_gmv"` + } + 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"). + 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) + + // 今日点击 + var todayCt clickTotal + _ = r.db.WithContext(ctx). + Table("ad_tracking_clicks"). + Select("COUNT(*) as pv, COUNT(DISTINCT visitor_id) as uv"). + Where("created_at >= ?", todayStart). + Scan(&todayCt).Error + stats.TodayPV = todayCt.PV + stats.TodayUV = todayCt.UV + + // 今日注册 + _ = r.db.WithContext(ctx). + Table("users"). + Where("register_track_code IS NOT NULL AND register_track_code != '' AND created_at >= ?", todayStart). + Count(&stats.TodayRegisters).Error + + // 今日订单 + 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"). + Where("source_track_code IS NOT NULL AND source_track_code != '' AND created_at >= ?", todayStart). + Scan(&todayOt).Error + stats.TodayOrders = todayOt.TotalOrders + stats.TodayPaidGMVCent = todayOt.TotalPaidGMV + + return stats, nil +} diff --git a/backend/internal/modules/adtracking/service.go b/backend/internal/modules/adtracking/service.go new file mode 100644 index 0000000..78d638a --- /dev/null +++ b/backend/internal/modules/adtracking/service.go @@ -0,0 +1,234 @@ +package adtracking + +import ( + "context" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +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}$`) +) + +var reservedCodes = map[string]bool{ + "admin": true, "api": true, "static": true, "assets": true, "login": true, + "register": true, "auth": true, "user": true, "order": true, "r": true, + "pay": true, "notice": true, "chat": true, "dashboard": true, +} + +type Service struct { + repo *Repository + redis *redis.Client +} + +func NewService(repo *Repository, redisClient *redis.Client) *Service { + return &Service{ + repo: repo, + redis: redisClient, + } +} + +func (s *Service) CreateLink(ctx context.Context, adminID uint64, req CreateTrackingLinkRequest) (*AdTrackingLink, error) { + code := strings.TrimSpace(req.Code) + if !codePattern.MatchString(code) { + return nil, ErrInvalidCodeFormat + } + if reservedCodes[strings.ToLower(code)] { + return nil, ErrReservedCode + } + + // 查重 + existing, _ := s.repo.FindLinkByCode(ctx, code) + if existing != nil { + 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 + } + + channelType := strings.TrimSpace(req.ChannelType) + if channelType == "" { + channelType = "offline_promo" + } + + link := &AdTrackingLink{ + Name: strings.TrimSpace(req.Name), + Code: code, + ChannelType: channelType, + TargetURL: targetURL, + Status: "active", + CostCent: req.CostCent, + Notes: strings.TrimSpace(req.Notes), + CreatedBy: adminID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := s.repo.CreateLink(ctx, link); err != nil { + return nil, fmt.Errorf("创建推广链接失败: %w", err) + } + + return link, nil +} + +func (s *Service) UpdateLink(ctx context.Context, id uint64, req UpdateTrackingLinkRequest) error { + link, err := s.repo.FindLinkByID(ctx, id) + if err != nil { + 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 + } + + channelType := strings.TrimSpace(req.ChannelType) + if channelType == "" { + channelType = link.ChannelType + } + + updates := map[string]interface{}{ + "name": strings.TrimSpace(req.Name), + "channel_type": channelType, + "target_url": targetURL, + "cost_cent": req.CostCent, + "notes": strings.TrimSpace(req.Notes), + "updated_at": time.Now(), + } + + return s.repo.UpdateLink(ctx, id, updates) +} + +func (s *Service) UpdateStatus(ctx context.Context, id uint64, status string) error { + if status != "active" && status != "disabled" { + return errors.New("状态值不合法") + } + _, err := s.repo.FindLinkByID(ctx, id) + if err != nil { + return ErrLinkNotFound + } + return s.repo.UpdateLink(ctx, id, map[string]interface{}{ + "status": status, + "updated_at": time.Now(), + }) +} + +func (s *Service) ListLinks(ctx context.Context, query ListTrackingLinksQuery, baseURL string) ([]TrackingLinkItem, int64, error) { + return s.repo.ListLinks(ctx, query, baseURL) +} + +func (s *Service) GetOverview(ctx context.Context) (*TrackingOverviewStats, error) { + return s.repo.GetOverviewStats(ctx) +} + +// ResolveRedirect 处理 302 短链跳转,并记录点击流水 +func (s *Service) ResolveRedirect(ctx context.Context, code, ip, ua, referer, visitorID string) (string, error) { + link, err := s.repo.FindLinkByCode(ctx, code) + if err != nil { + return "", ErrLinkNotFound + } + if link.Status != "active" { + return "", ErrLinkDisabled + } + + // 异步记录点击流水,防止写库阻塞跳转 + deviceType, os, browser := parseUserAgent(ua) + go func() { + bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + click := &AdTrackingClick{ + LinkID: link.ID, + Code: link.Code, + IP: ip, + UserAgent: truncateString(ua, 500), + DeviceType: deviceType, + OS: os, + Browser: browser, + Referer: truncateString(referer, 500), + VisitorID: visitorID, + CreatedAt: time.Now(), + } + _ = s.repo.RecordClick(bgCtx, click) + }() + + // 拼接带推广短码的落地页 URL (例如 https://hao858.com/?track_code=wb01) + finalURL := appendTrackCode(link.TargetURL, link.Code) + return finalURL, nil +} + +func appendTrackCode(targetURL, code string) string { + u, err := url.Parse(targetURL) + if err != nil { + return targetURL + } + q := u.Query() + q.Set("track_code", code) + u.RawQuery = q.Encode() + return u.String() +} + +func truncateString(str string, maxLen int) string { + if len(str) > maxLen { + return str[:maxLen] + } + return str +} + +func parseUserAgent(ua string) (deviceType, os, browser string) { + deviceType = "pc" + os = "unknown" + browser = "unknown" + + lowerUA := strings.ToLower(ua) + if strings.Contains(lowerUA, "mobile") || strings.Contains(lowerUA, "android") || strings.Contains(lowerUA, "iphone") { + deviceType = "mobile" + } else if strings.Contains(lowerUA, "ipad") || strings.Contains(lowerUA, "tablet") { + deviceType = "tablet" + } + + // OS 检测 (优先移动端避免 iPhone UA 中的 Mac OS X 干扰) + if strings.Contains(lowerUA, "iphone") || strings.Contains(lowerUA, "ios") { + os = "iOS" + } else if strings.Contains(lowerUA, "android") { + os = "Android" + } else if strings.Contains(lowerUA, "windows") { + os = "Windows" + } else if strings.Contains(lowerUA, "mac os") || strings.Contains(lowerUA, "macintosh") { + os = "macOS" + } else if strings.Contains(lowerUA, "linux") { + os = "Linux" + } + + // Browser 检测 + if strings.Contains(lowerUA, "micromessenger") { + browser = "WeChat" + } else if strings.Contains(lowerUA, "edg/") || strings.Contains(lowerUA, "edge/") { + browser = "Edge" + } else if strings.Contains(lowerUA, "chrome") { + browser = "Chrome" + } else if strings.Contains(lowerUA, "safari") { + browser = "Safari" + } else if strings.Contains(lowerUA, "firefox") { + browser = "Firefox" + } + + return deviceType, os, browser +} diff --git a/backend/internal/modules/adtracking/service_test.go b/backend/internal/modules/adtracking/service_test.go new file mode 100644 index 0000000..5675e63 --- /dev/null +++ b/backend/internal/modules/adtracking/service_test.go @@ -0,0 +1,90 @@ +package adtracking + +import ( + "testing" +) + +func TestAppendTrackCode(t *testing.T) { + tests := []struct { + target string + code string + expected string + }{ + { + target: "https://hao858.com", + code: "wb01", + expected: "https://hao858.com?track_code=wb01", + }, + { + target: "https://hao858.com/", + code: "campus99", + expected: "https://hao858.com/?track_code=campus99", + }, + { + target: "https://hao858.com/listings?game=delta", + code: "poster01", + expected: "https://hao858.com/listings?game=delta&track_code=poster01", + }, + } + + for _, tt := range tests { + got := appendTrackCode(tt.target, tt.code) + if got != tt.expected { + t.Errorf("appendTrackCode(%q, %q) = %q; want %q", tt.target, tt.code, got, tt.expected) + } + } +} + +func TestParseUserAgent(t *testing.T) { + tests := []struct { + ua string + wantDeviceType string + wantOS string + wantBrowser string + }{ + { + ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + wantDeviceType: "pc", + wantOS: "macOS", + wantBrowser: "Chrome", + }, + { + ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.38", + wantDeviceType: "mobile", + wantOS: "iOS", + wantBrowser: "WeChat", + }, + { + ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0", + wantDeviceType: "pc", + wantOS: "Windows", + wantBrowser: "Edge", + }, + } + + for _, tt := range tests { + deviceType, _, browser := parseUserAgent(tt.ua) + if deviceType != tt.wantDeviceType { + t.Errorf("parseUserAgent(%q) deviceType = %q; want %q", tt.ua, deviceType, tt.wantDeviceType) + } + if browser != tt.wantBrowser { + t.Errorf("parseUserAgent(%q) browser = %q; want %q", tt.ua, browser, tt.wantBrowser) + } + } +} + +func TestCodePattern(t *testing.T) { + validCodes := []string{"wb01", "campus_2026", "poster-hefei", "NetBar99"} + for _, c := range validCodes { + if !codePattern.MatchString(c) { + t.Errorf("expected valid code %q", c) + } + } + + invalidCodes := []string{"a", "has space", "bad@code", "中文短码", "a!b"} + for _, c := range invalidCodes { + if codePattern.MatchString(c) { + t.Errorf("expected invalid code %q", c) + } + } +} diff --git a/backend/internal/modules/auth/handler.go b/backend/internal/modules/auth/handler.go index bdc7ed5..5c53949 100644 --- a/backend/internal/modules/auth/handler.go +++ b/backend/internal/modules/auth/handler.go @@ -21,8 +21,9 @@ type SendSMSRequest struct { } type LoginRequest struct { - Phone string `json:"phone" binding:"required"` - Code string `json:"code" binding:"required,len=6"` + Phone string `json:"phone" binding:"required"` + Code string `json:"code" binding:"required,len=6"` + TrackCode string `json:"track_code"` } type RefreshRequest struct { @@ -40,9 +41,10 @@ type SetPasswordRequest struct { } type RegisterRequest struct { - Phone string `json:"phone" binding:"required"` - Code string `json:"code" binding:"required,len=6"` - Password string `json:"password" binding:"required,min=8,max=20"` + Phone string `json:"phone" binding:"required"` + Code string `json:"code" binding:"required,len=6"` + Password string `json:"password" binding:"required,min=8,max=20"` + TrackCode string `json:"track_code"` } type ResetPasswordRequest struct { @@ -106,7 +108,7 @@ func (h *Handler) Login(c *gin.Context) { response.BadRequest(c, "手机号和验证码不能为空") return } - result, err := h.service.LoginWithSMS(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code)) + result, err := h.service.LoginWithSMS(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), strings.TrimSpace(req.TrackCode)) if err != nil { writeAuthError(c, err) return @@ -176,7 +178,7 @@ func (h *Handler) Register(c *gin.Context) { response.BadRequest(c, "手机号、验证码和密码不能为空,密码长度 8-20 位") return } - result, err := h.service.RegisterWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.Password) + result, err := h.service.RegisterWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.Password, strings.TrimSpace(req.TrackCode)) if err != nil { writeAuthError(c, err) return diff --git a/backend/internal/modules/auth/repository.go b/backend/internal/modules/auth/repository.go index 2027f89..5a9f8bc 100644 --- a/backend/internal/modules/auth/repository.go +++ b/backend/internal/modules/auth/repository.go @@ -3,6 +3,7 @@ package auth import ( "context" "errors" + "strings" "time" "hfb_sys/backend/internal/model" @@ -57,16 +58,21 @@ func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname return r.FindByID(ctx, id) } -func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string) (*model.User, error) { +func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string, trackCode ...string) (*model.User, error) { now := time.Now() + var code string + if len(trackCode) > 0 { + code = strings.TrimSpace(trackCode[0]) + } user := model.User{ - Phone: phone, - Nickname: "用户" + phone[len(phone)-4:], - RealnameStatus: "unverified", - RiskStatus: "normal", - CreditScore: 100, - Status: "active", - LastLoginAt: &now, + Phone: phone, + Nickname: "用户" + phone[len(phone)-4:], + RealnameStatus: "unverified", + RiskStatus: "normal", + CreditScore: 100, + Status: "active", + RegisterTrackCode: code, + LastLoginAt: &now, } err := r.db.WithContext(ctx).Clauses(clause.OnConflict{ @@ -96,17 +102,22 @@ func (r *UserRepository) SetPassword(ctx context.Context, userID uint64, hash st return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Update("password_hash", hash).Error } -func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string) (*model.User, error) { +func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string, trackCode ...string) (*model.User, error) { now := time.Now() + var code string + if len(trackCode) > 0 { + code = strings.TrimSpace(trackCode[0]) + } user := model.User{ - Phone: phone, - PasswordHash: hash, - Nickname: "用户" + phone[len(phone)-4:], - RealnameStatus: "unverified", - RiskStatus: "normal", - CreditScore: 100, - Status: "active", - LastLoginAt: &now, + Phone: phone, + PasswordHash: hash, + Nickname: "用户" + phone[len(phone)-4:], + RealnameStatus: "unverified", + RiskStatus: "normal", + CreditScore: 100, + Status: "active", + RegisterTrackCode: code, + LastLoginAt: &now, } err := r.db.WithContext(ctx).Create(&user).Error diff --git a/backend/internal/modules/auth/service.go b/backend/internal/modules/auth/service.go index 6a37b4c..6d42b31 100644 --- a/backend/internal/modules/auth/service.go +++ b/backend/internal/modules/auth/service.go @@ -157,7 +157,7 @@ func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID strin return nil } -func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (LoginResult, error) { +func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string, trackCode ...string) (LoginResult, error) { if s.redis == nil || s.users == nil { return LoginResult{}, ErrDependencyUnavailable } @@ -173,7 +173,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) ( return LoginResult{}, err } - user, err := s.users.FindOrCreateByPhone(ctx, phone) + user, err := s.users.FindOrCreateByPhone(ctx, phone, trackCode...) if err != nil { return LoginResult{}, err } @@ -342,7 +342,7 @@ func (s *Service) ResetPassword(ctx context.Context, phone, code, newPassword st return nil } -func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, password string) (LoginResult, error) { +func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, password string, trackCode ...string) (LoginResult, error) { if s.redis == nil || s.users == nil { return LoginResult{}, ErrDependencyUnavailable } @@ -375,7 +375,7 @@ func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, passwor return LoginResult{}, err } - user, err := s.users.RegisterWithPassword(ctx, phone, string(hash)) + user, err := s.users.RegisterWithPassword(ctx, phone, string(hash), trackCode...) if err != nil { // 并发场景下仍可能因唯一索引冲突,统一按"已注册"处理 return LoginResult{}, ErrUserAlreadyExists diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index ef3ca76..5f09348 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -75,6 +75,7 @@ type OrderDTO struct { Checkout *CheckoutDTO `json:"checkout,omitempty"` AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"` PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"` + SourceTrackCode string `json:"source_track_code,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -105,6 +106,7 @@ type ActiveDisputeDTO struct { type CreateRequest struct { ListingID uint64 `json:"listing_id" binding:"required"` + TrackCode string `json:"track_code,omitempty"` } type SubmitHandoffRequest struct { diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index f6e303c..8a9cec8 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -3,6 +3,7 @@ package order import ( "context" "encoding/json" + "strings" "time" "hfb_sys/backend/internal/model" @@ -70,6 +71,13 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ if err != nil { return err } + sourceTrackCode := strings.TrimSpace(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 + } + } order := model.RentalOrder{ OrderNo: orderNo, ListingID: listing.ID, @@ -103,6 +111,7 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ ManagedAdminID: listing.ManagedAdminID, SettlementStatus: settlementStatusUnsettled, OfflineSettlementStatus: offlineSettlementStatusNone, + SourceTrackCode: sourceTrackCode, } if err := tx.Create(&order).Error; err != nil { return err diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index 2abe4b7..e925d3c 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -128,6 +128,7 @@ func (row orderRow) toAdminDTO() OrderDTO { DepositHeldAt: row.DepositHeldAt, DepositHoldReleasedAt: row.DepositHoldReleasedAt, AdminActions: adminActionsForOrder(row.RentalOrder), + SourceTrackCode: row.SourceTrackCode, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 57bd03d..2701434 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -6,6 +6,7 @@ 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" @@ -372,6 +373,13 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } announcementService := announcement.NewService(announcementRepo) announcementHandler := announcement.NewHandler(announcementService) + var adTrackingRepo *adtracking.Repository + if deps.DB != nil { + adTrackingRepo = adtracking.NewRepository(deps.DB) + } + adTrackingService := adtracking.NewService(adTrackingRepo, deps.Redis) + adTrackingHandler := adtracking.NewHandler(adTrackingService) + engine.GET("/r/:code", adTrackingHandler.Redirect) validateUserToken := func(ctx context.Context, userID uint64, tokenVersion int64) error { if userRepo == nil { return auth.ErrDependencyUnavailable @@ -729,6 +737,13 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/chats/listing-group-welcome", requirePerm("system_config:view"), chatHandler.AdminGetListingGroupWelcome) adminRoutes.PUT("/chats/listing-group-welcome", requirePerm("system_config:update"), chatHandler.AdminUpdateListingGroupWelcome) + // 推广链接与数据追踪管理 + adminRoutes.GET("/tracking-links/overview", requirePerm("ad_tracking:manage"), adTrackingHandler.Overview) + adminRoutes.GET("/tracking-links", requirePerm("ad_tracking:manage"), adTrackingHandler.List) + adminRoutes.POST("/tracking-links", requirePerm("ad_tracking:manage"), adTrackingHandler.Create) + adminRoutes.PUT("/tracking-links/:id", requirePerm("ad_tracking:manage"), adTrackingHandler.Update) + adminRoutes.PUT("/tracking-links/:id/status", requirePerm("ad_tracking:manage"), adTrackingHandler.UpdateStatus) + registerAdminManagementRoutes(adminRoutes, requirePerm, adminManagementHandlers{ chat: chatHandler, supportGroup: supportGroupHandler, role: adminRoleHandler, admin: adminMgrHandler, announcement: announcementHandler, diff --git a/backend/migrations/000064_ad_tracking.sql b/backend/migrations/000064_ad_tracking.sql new file mode 100644 index 0000000..46183b5 --- /dev/null +++ b/backend/migrations/000064_ad_tracking.sql @@ -0,0 +1,73 @@ +-- +goose Up + +CREATE TABLE ad_tracking_links ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(128) NOT NULL COMMENT '推广名称', + code VARCHAR(64) NOT NULL COMMENT '自定义短码后缀', + channel_type VARCHAR(64) NOT NULL DEFAULT 'other' COMMENT '渠道场景类型: cybercafe/campus/offline_promo/partner/online_ad/other', + target_url VARCHAR(512) NOT NULL DEFAULT 'https://hao858.com/' COMMENT '目标跳转落地页', + status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态: active启用/disabled停用', + cost_cent BIGINT NOT NULL DEFAULT 0 COMMENT '投放预算或成本(分)', + notes VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注说明', + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建管理员ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_ad_tracking_links_code (code), + KEY idx_ad_tracking_links_status (status), + KEY idx_ad_tracking_links_channel (channel_type), + KEY idx_ad_tracking_links_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='广告与推广追踪链接'; + +CREATE TABLE ad_tracking_clicks ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + link_id BIGINT UNSIGNED NOT NULL COMMENT '推广链接ID', + code VARCHAR(64) NOT NULL COMMENT '推广短码', + ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '访问客户端IP', + user_agent VARCHAR(512) NOT NULL DEFAULT '' COMMENT '客户端UA', + device_type VARCHAR(32) NOT NULL DEFAULT 'unknown' COMMENT '设备类型: pc/mobile/tablet/unknown', + os VARCHAR(32) NOT NULL DEFAULT 'unknown' COMMENT '操作系统', + browser VARCHAR(32) NOT NULL DEFAULT 'unknown' COMMENT '浏览器', + referer VARCHAR(512) NOT NULL DEFAULT '' COMMENT '来源Referer', + visitor_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '访客终端唯一ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_ad_tracking_clicks_link_created (link_id, created_at), + KEY idx_ad_tracking_clicks_code_created (code, created_at), + KEY idx_ad_tracking_clicks_ip_created (ip, created_at), + KEY idx_ad_tracking_clicks_visitor (visitor_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广链接跳转点击流水'; + +ALTER TABLE users ADD COLUMN register_track_code VARCHAR(64) NULL DEFAULT NULL COMMENT '注册来源推广短码'; +ALTER TABLE users ADD KEY idx_users_register_track_code (register_track_code); + +ALTER TABLE rental_orders ADD COLUMN source_track_code VARCHAR(64) NULL DEFAULT NULL COMMENT '订单来源推广短码'; +ALTER TABLE rental_orders ADD KEY idx_rental_orders_source_track_code (source_track_code); + +INSERT INTO permissions (code, name, resource, action) VALUES +('ad_tracking:manage', '管理推广链接与数据追踪', 'ad_tracking', 'manage') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code IN ('super_admin', 'operator') AND p.code = 'ad_tracking:manage'; + +-- +goose Down + +DELETE rp FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code = 'ad_tracking:manage'; + +DELETE FROM permissions WHERE code = 'ad_tracking:manage'; + +ALTER TABLE rental_orders DROP KEY idx_rental_orders_source_track_code; +ALTER TABLE rental_orders DROP COLUMN source_track_code; + +ALTER TABLE users DROP KEY idx_users_register_track_code; +ALTER TABLE users DROP COLUMN register_track_code; + +DROP TABLE IF EXISTS ad_tracking_clicks; +DROP TABLE IF EXISTS ad_tracking_links; diff --git a/frontend/src/features/admin/api/adTracking.ts b/frontend/src/features/admin/api/adTracking.ts new file mode 100644 index 0000000..2398adc --- /dev/null +++ b/frontend/src/features/admin/api/adTracking.ts @@ -0,0 +1,110 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse } from '@/shared/types/types' + +export interface TrackingLinkItem { + id: number + name: string + code: string + channel_type: string + target_url: string + short_url: string + status: 'active' | 'disabled' + cost_cent: number + notes: string + created_by: number + created_at: string + updated_at: string + + click_pv: number + click_uv: number + click_ip: number + register_count: number + order_count: number + paid_order_count: number + paid_gmv_cent: number + conversion_rate: number +} + +export interface TrackingOverviewStats { + total_links: number + active_links: number + total_pv: number + total_uv: number + total_ip: number + total_registers: number + total_orders: number + total_paid_orders: number + total_paid_gmv_cent: number + + today_pv: number + today_uv: number + today_registers: number + today_orders: number + today_paid_gmv_cent: number +} + +export interface ListTrackingLinksParams { + keyword?: string + channel_type?: string + status?: string + page?: number + page_size?: number +} + +export interface CreateTrackingLinkPayload { + name: string + code: string + channel_type?: string + target_url?: string + cost_cent?: number + notes?: string +} + +export interface UpdateTrackingLinkPayload { + name: string + channel_type?: string + target_url?: string + cost_cent?: number + notes?: string +} + +export async function fetchTrackingOverview(): Promise { + const { data } = await apiClient.get>( + '/admin/tracking-links/overview' + ) + return data.data +} + +export async function fetchTrackingLinks( + params: ListTrackingLinksParams +): Promise<{ items: TrackingLinkItem[]; total: number }> { + const { data } = await apiClient.get>( + '/admin/tracking-links', + { params } + ) + return data.data +} + +export async function createTrackingLink( + payload: CreateTrackingLinkPayload +): Promise { + const { data } = await apiClient.post>( + '/admin/tracking-links', + payload + ) + return data.data +} + +export async function updateTrackingLink( + id: number, + payload: UpdateTrackingLinkPayload +): Promise { + await apiClient.put(`/admin/tracking-links/${id}`, payload) +} + +export async function updateTrackingLinkStatus( + id: number, + status: 'active' | 'disabled' +): Promise { + await apiClient.put(`/admin/tracking-links/${id}/status`, { status }) +} diff --git a/frontend/src/features/admin/views/AdminTrackingLinksView.vue b/frontend/src/features/admin/views/AdminTrackingLinksView.vue new file mode 100644 index 0000000..c41bcee --- /dev/null +++ b/frontend/src/features/admin/views/AdminTrackingLinksView.vue @@ -0,0 +1,1048 @@ + + + + + diff --git a/frontend/src/features/auth/api/auth.ts b/frontend/src/features/auth/api/auth.ts index 8406864..3c1387d 100644 --- a/frontend/src/features/auth/api/auth.ts +++ b/frontend/src/features/auth/api/auth.ts @@ -1,6 +1,7 @@ import { apiClient } from '@/shared/api/client' import type { ApiResponse } from '@/shared/types/types' import type { RealnameStatusValue, RiskStatus, UserStatus } from '@/shared/types/status' +import { getTrackCode } from '@/shared/utils/trackCode' export interface AuthUser { id: number @@ -51,7 +52,12 @@ export async function sendSmsCode(phone: string, captchaId: string, captchaCode: } export async function loginWithSms(phone: string, code: string) { - const { data } = await apiClient.post>('/auth/sms/login', { phone, code }) + const trackCode = getTrackCode() + const payload: Record = { phone, code } + if (trackCode) { + payload.track_code = trackCode + } + const { data } = await apiClient.post>('/auth/sms/login', payload) return data.data } @@ -64,11 +70,12 @@ export async function loginWithPassword(phone: string, password: string) { } export async function registerWithPassword(phone: string, code: string, password: string) { - const { data } = await apiClient.post>('/auth/password/register', { - phone, - code, - password, - }) + const trackCode = getTrackCode() + const payload: Record = { phone, code, password } + if (trackCode) { + payload.track_code = trackCode + } + const { data } = await apiClient.post>('/auth/password/register', payload) return data.data } diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 8baaf7e..12f91f9 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -2,6 +2,7 @@ import { apiClient } from '@/shared/api/client' import type { ApiResponse, PaginatedResult } from '@/shared/types/types' import type { HandoffStatus, OrderStatus, SettlementStatus } from '@/shared/types/status' import { yuanToCent } from '@/shared/utils/money' +import { getTrackCode } from '@/shared/utils/trackCode' export interface Order { id: number @@ -280,7 +281,12 @@ export async function fetchOrder(id: string | number) { } export async function createOrder(listingId: number) { - const { data } = await apiClient.post>('/orders', { listing_id: listingId }) + const trackCode = getTrackCode() + const payload: Record = { listing_id: listingId } + if (trackCode) { + payload.track_code = trackCode + } + const { data } = await apiClient.post>('/orders', payload) return data.data } diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 4019d54..e9975b8 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -16,6 +16,8 @@ import { Operation, Picture, Present, + Promotion, + Link, ScaleToOriginal, Service, Shop, @@ -131,6 +133,19 @@ const allNavGroups: NavGroup[] = [ }, ], }, + { + label: '推广追踪', + index: 'ad-tracking', + icon: Promotion, + children: [ + { + label: '推广链接管理', + to: adminPath('tracking-links'), + icon: Link, + permission: 'ad_tracking:manage', + }, + ], + }, { label: '客服与风控', index: 'support-risk', diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index 3df265a..cf46002 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -216,4 +216,10 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminAnnouncementsView.vue'), meta: adminMeta, }, + { + path: adminPath('tracking-links'), + name: 'admin-tracking-links', + component: () => import('@/features/admin/views/AdminTrackingLinksView.vue'), + meta: adminMeta, + }, ] diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8b2d8f5..40ba0d1 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -10,6 +10,7 @@ import { isMobileHost, isMobileDevice, getMobilePath, getPcPath } from './mobile import { mobileRoutes } from './mobileRoutes' import { publicRoutes } from './publicRoutes' import { sellerRoutes } from './sellerRoutes' +import { saveTrackCode } from '@/shared/utils/trackCode' const router = createRouter({ history: createWebHistory(), @@ -27,6 +28,10 @@ const router = createRouter({ }) router.beforeEach(async to => { + if (to.query.track_code) { + saveTrackCode(String(to.query.track_code)) + } + const isMobile = isMobileHost() || isMobileDevice() if (isMobile) { diff --git a/frontend/src/shared/utils/trackCode.ts b/frontend/src/shared/utils/trackCode.ts new file mode 100644 index 0000000..61769dc --- /dev/null +++ b/frontend/src/shared/utils/trackCode.ts @@ -0,0 +1,46 @@ +const TRACK_CODE_KEY = 'hfb_track_code' +const TRACK_CODE_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天有效 + +interface StoredTrackCode { + code: string + timestamp: number +} + +export function saveTrackCode(code: string): void { + if (!code) return + const cleanCode = code.trim() + if (!cleanCode) return + try { + const item: StoredTrackCode = { + code: cleanCode, + timestamp: Date.now(), + } + localStorage.setItem(TRACK_CODE_KEY, JSON.stringify(item)) + } catch { + // 忽略 localStorage 存取异常 + } +} + +export function getTrackCode(): string { + try { + const raw = localStorage.getItem(TRACK_CODE_KEY) + if (!raw) return '' + const item: StoredTrackCode = JSON.parse(raw) + if (!item || !item.code) return '' + if (Date.now() - item.timestamp > TRACK_CODE_TTL_MS) { + localStorage.removeItem(TRACK_CODE_KEY) + return '' + } + return item.code + } catch { + return '' + } +} + +export function clearTrackCode(): void { + try { + localStorage.removeItem(TRACK_CODE_KEY) + } catch { + // 忽略 + } +} From 6a3611c1a809aa2f01fa32e063dc09a6ddd0f36f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 10 Sep 2026 11:17:40 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(adtracking):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=E7=BD=91=E5=85=B3=E5=8F=8D=E4=BB=A3=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E3=80=81=E6=88=90=E4=BA=A4=E6=80=81=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E5=8F=A3=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', From ee0e70ccada631b36c549e9a1eb36455fa2f6a70 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 10 Sep 2026 11:27:43 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(adtracking):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E6=88=90=E4=BA=A4=E5=8F=A3=E5=BE=84=E3=80=81=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E5=8E=BB=E9=87=8D=E4=B8=8E=E7=94=9F=E4=BA=A7=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=E9=A1=B5=E7=99=BD=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 成交 GMV 排除已关闭订单,避免未支付关单被计入;点击按访客 5 秒、IP 1 秒双重去重;生产禁止 localhost 落地页,失效短码改用 QueryEscape。 Co-authored-by: Cursor --- .../internal/modules/adtracking/handler.go | 9 +-- .../internal/modules/adtracking/repository.go | 14 ++--- .../internal/modules/adtracking/service.go | 55 ++++++++++++------- .../modules/adtracking/service_test.go | 32 ++++++----- backend/internal/router/router.go | 4 +- frontend/src/shared/utils/trackCode.ts | 11 +++- 6 files changed, 73 insertions(+), 52 deletions(-) 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 '' }