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 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 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 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 stats.TotalPaidOrders = ot.TotalPaidOrders stats.TotalPaidGMVCent = ot.TotalPaidGMV // 今日数据计算(按本地时区获取今日 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 _ = 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 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 stats.TodayPaidGMVCent = todayOt.TotalPaidGMV return stats, nil }