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:
+2
-1
@@ -45,4 +45,5 @@ logs
|
||||
artifacts/
|
||||
|
||||
frontend/coverage/
|
||||
.zcode/
|
||||
.zcode/
|
||||
.workbuddy/
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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<TrackingOverviewStats> {
|
||||
const { data } = await apiClient.get<ApiResponse<TrackingOverviewStats>>(
|
||||
'/admin/tracking-links/overview'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchTrackingLinks(
|
||||
params: ListTrackingLinksParams
|
||||
): Promise<{ items: TrackingLinkItem[]; total: number }> {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: TrackingLinkItem[]; total: number }>>(
|
||||
'/admin/tracking-links',
|
||||
{ params }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createTrackingLink(
|
||||
payload: CreateTrackingLinkPayload
|
||||
): Promise<TrackingLinkItem> {
|
||||
const { data } = await apiClient.post<ApiResponse<TrackingLinkItem>>(
|
||||
'/admin/tracking-links',
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateTrackingLink(
|
||||
id: number,
|
||||
payload: UpdateTrackingLinkPayload
|
||||
): Promise<void> {
|
||||
await apiClient.put(`/admin/tracking-links/${id}`, payload)
|
||||
}
|
||||
|
||||
export async function updateTrackingLinkStatus(
|
||||
id: number,
|
||||
status: 'active' | 'disabled'
|
||||
): Promise<void> {
|
||||
await apiClient.put(`/admin/tracking-links/${id}/status`, { status })
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<ApiResponse<LoginData>>('/auth/sms/login', { phone, code })
|
||||
const trackCode = getTrackCode()
|
||||
const payload: Record<string, unknown> = { phone, code }
|
||||
if (trackCode) {
|
||||
payload.track_code = trackCode
|
||||
}
|
||||
const { data } = await apiClient.post<ApiResponse<LoginData>>('/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<ApiResponse<LoginData>>('/auth/password/register', {
|
||||
phone,
|
||||
code,
|
||||
password,
|
||||
})
|
||||
const trackCode = getTrackCode()
|
||||
const payload: Record<string, unknown> = { phone, code, password }
|
||||
if (trackCode) {
|
||||
payload.track_code = trackCode
|
||||
}
|
||||
const { data } = await apiClient.post<ApiResponse<LoginData>>('/auth/password/register', payload)
|
||||
return data.data
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ApiResponse<Order>>('/orders', { listing_id: listingId })
|
||||
const trackCode = getTrackCode()
|
||||
const payload: Record<string, unknown> = { listing_id: listingId }
|
||||
if (trackCode) {
|
||||
payload.track_code = trackCode
|
||||
}
|
||||
const { data } = await apiClient.post<ApiResponse<Order>>('/orders', payload)
|
||||
return data.data
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user