Files
hfb_sys/backend/internal/modules/adtracking/repository.go
T
yml2213andCursor ee0e70ccad fix(adtracking): 收紧成交口径、点击去重与生产落地页白名单
成交 GMV 排除已关闭订单,避免未支付关单被计入;点击按访客 5 秒、IP 1 秒双重去重;生产禁止 localhost 落地页,失效短码改用 QueryEscape。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 11:27:43 +08:00

268 lines
8.3 KiB
Go

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
}