成交 GMV 排除已关闭订单,避免未支付关单被计入;点击按访客 5 秒、IP 1 秒双重去重;生产禁止 localhost 落地页,失效短码改用 QueryEscape。 Co-authored-by: Cursor <cursoragent@cursor.com>
268 lines
8.3 KiB
Go
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
|
|
}
|