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:
yml2213
2026-09-10 11:05:47 +08:00
parent 33ee8408e9
commit 2b11d70ebe
25 changed files with 2304 additions and 36 deletions
+2 -1
View File
@@ -45,4 +45,5 @@ logs
artifacts/
frontend/coverage/
.zcode/
.zcode/
.workbuddy/
+1
View File
@@ -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"`
}
+1
View File
@@ -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)
}
}
}
+9 -7
View File
@@ -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
+28 -17
View File
@@ -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
+4 -4
View File
@@ -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
+2
View File
@@ -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,
}
+15
View File
@@ -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,
+73
View File
@@ -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
+13 -6
View File
@@ -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
}
+7 -1
View File
@@ -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
}
+15
View File
@@ -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',
+6
View File
@@ -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,
},
]
+5
View File
@@ -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) {
+46
View File
@@ -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 {
// 忽略
}
}