feat(adtracking): 新增推广链接追踪与数据大盘,支持自定义短码、注册与下单归因及二维码物料生成
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)
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user