Merge branch 'main' into feature/distribution-system
合入 main 的推广追踪(adtracking)模块,并解决与分销业务的 8 处冲突。 冲突解决原则:两边的字段、菜单与路由全部保留,不删任何一方。 后端: - model/order.go:保留分销字段注释块,追加 SourceTrackCode - model/user.go:追加 RegisterTrackCode;TokenVersion 默认值保持 1 (与本库 users.token_version 列默认值一致) - auth/repository.go:两处建号结构体同时写入 RegisterTrackCode 与 TokenVersion - order/dto.go:OrderDTO 同时暴露 distribution_site_id 与 source_track_code; CreateRequest 同时接受 distribution_site_code 与 track_code - order/presenter.go:同时映射 DistributionSiteID 与 SourceTrackCode 前端: - orders/api/orders.ts:createOrder 同时携带 distribution_site_code 与 track_code - layouts/AdminLayout.vue:分销管理菜单组与推广追踪菜单组并列保留 - router/adminRoutes.ts:分销 4 条路由与 tracking-links 路由并列保留 迁移:feature 侧 000064-000067 已在前一提交顺延为 000065-000068, 为 main 的 000064_ad_tracking 让号,合并后编号 000063→000068 连续无重复。
This commit is contained in:
@@ -75,8 +75,10 @@ type RentalOrder struct {
|
||||
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at"`
|
||||
OwnerSettledAt *time.Time `json:"owner_settled_at"`
|
||||
SettledAt *time.Time `json:"settled_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_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"`
|
||||
}
|
||||
|
||||
func (RentalOrder) TableName() string {
|
||||
|
||||
@@ -3,22 +3,25 @@ package model
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"`
|
||||
PasswordHash string `gorm:"size:255;not null;default:''" json:"-"`
|
||||
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
||||
AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"`
|
||||
RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"`
|
||||
RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"`
|
||||
CreditScore int `gorm:"not null;default:100" json:"credit_score"`
|
||||
DepositFreeQuotaCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"`
|
||||
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:1" json:"-"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"`
|
||||
PasswordHash string `gorm:"size:255;not null;default:''" json:"-"`
|
||||
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
||||
AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"`
|
||||
RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"`
|
||||
RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"`
|
||||
CreditScore int `gorm:"not null;default:100" json:"credit_score"`
|
||||
DepositFreeQuotaCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"`
|
||||
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"`
|
||||
// 会话版本:默认与 users.token_version 列默认值保持一致,递增后旧令牌失效。
|
||||
TokenVersion int64 `gorm:"not null;default:1" 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"`
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
|
||||
@@ -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,202 @@
|
||||
package adtracking
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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 !codePattern.MatchString(code) {
|
||||
c.Redirect(http.StatusFound, "https://hao858.com/")
|
||||
return
|
||||
}
|
||||
|
||||
visitorID, err := c.Cookie("hfb_v_id")
|
||||
if err != nil || visitorID == "" {
|
||||
visitorID = generateVisitorID()
|
||||
isSecure := c.Request.TLS != nil || c.Request.Header.Get("X-Forwarded-Proto") == "https"
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie("hfb_v_id", visitorID, 31536000, "/", "", isSecure, true)
|
||||
}
|
||||
|
||||
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="+url.QueryEscape(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 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), errors.Is(err, ErrDisallowedTargetDomain):
|
||||
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,267 @@
|
||||
package adtracking
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) CreateLink(ctx context.Context, link *AdTrackingLink) error {
|
||||
return r.db.WithContext(ctx).Create(link).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateLink(ctx context.Context, id uint64, updates map[string]interface{}) error {
|
||||
return r.db.WithContext(ctx).Model(&AdTrackingLink{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *Repository) FindLinkByID(ctx context.Context, id uint64) (*AdTrackingLink, error) {
|
||||
var link AdTrackingLink
|
||||
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&link).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &link, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindLinkByCode(ctx context.Context, code string) (*AdTrackingLink, error) {
|
||||
var link AdTrackingLink
|
||||
if err := r.db.WithContext(ctx).Where("code = ?", code).First(&link).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &link, nil
|
||||
}
|
||||
|
||||
func (r *Repository) RecordClick(ctx context.Context, click *AdTrackingClick) error {
|
||||
return r.db.WithContext(ctx).Create(click).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ListLinks(ctx context.Context, query ListTrackingLinksQuery, baseURL string) ([]TrackingLinkItem, int64, error) {
|
||||
tx := r.db.WithContext(ctx).Model(&AdTrackingLink{})
|
||||
|
||||
if kw := strings.TrimSpace(query.Keyword); kw != "" {
|
||||
like := "%" + kw + "%"
|
||||
tx = tx.Where("name LIKE ? OR code LIKE ? OR notes LIKE ?", like, like, like)
|
||||
}
|
||||
if ct := strings.TrimSpace(query.ChannelType); ct != "" && ct != "all" {
|
||||
tx = tx.Where("channel_type = ?", ct)
|
||||
}
|
||||
if st := strings.TrimSpace(query.Status); st != "" && st != "all" {
|
||||
tx = tx.Where("status = ?", st)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
page := query.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := query.PageSize
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
var links []AdTrackingLink
|
||||
if err := tx.Order("id DESC").Offset(offset).Limit(pageSize).Find(&links).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if len(links) == 0 {
|
||||
return []TrackingLinkItem{}, total, nil
|
||||
}
|
||||
|
||||
linkIDs := make([]uint64, 0, len(links))
|
||||
codes := make([]string, 0, len(links))
|
||||
for _, l := range links {
|
||||
linkIDs = append(linkIDs, l.ID)
|
||||
codes = append(codes, l.Code)
|
||||
}
|
||||
|
||||
// 1. 批量聚合点击数据 (PV, UV, IP)
|
||||
type clickStat struct {
|
||||
LinkID uint64 `gorm:"column:link_id"`
|
||||
PV int64 `gorm:"column:pv"`
|
||||
UV int64 `gorm:"column:uv"`
|
||||
IP int64 `gorm:"column:ips"`
|
||||
}
|
||||
var clickStats []clickStat
|
||||
_ = r.db.WithContext(ctx).
|
||||
Table("ad_tracking_clicks").
|
||||
Select("link_id, COUNT(*) as pv, COUNT(DISTINCT visitor_id) as uv, COUNT(DISTINCT ip) as ips").
|
||||
Where("link_id IN (?)", linkIDs).
|
||||
Group("link_id").
|
||||
Scan(&clickStats).Error
|
||||
|
||||
clickStatMap := make(map[uint64]clickStat, len(clickStats))
|
||||
for _, cs := range clickStats {
|
||||
clickStatMap[cs.LinkID] = cs
|
||||
}
|
||||
|
||||
// 2. 批量聚合注册人数
|
||||
type userStat struct {
|
||||
TrackCode string `gorm:"column:register_track_code"`
|
||||
Count int64 `gorm:"column:reg_count"`
|
||||
}
|
||||
var userStats []userStat
|
||||
_ = r.db.WithContext(ctx).
|
||||
Table("users").
|
||||
Select("register_track_code, COUNT(*) as reg_count").
|
||||
Where("register_track_code IN (?)", codes).
|
||||
Group("register_track_code").
|
||||
Scan(&userStats).Error
|
||||
|
||||
userStatMap := make(map[string]int64, len(userStats))
|
||||
for _, us := range userStats {
|
||||
userStatMap[us.TrackCode] = us.Count
|
||||
}
|
||||
|
||||
// 3. 批量聚合订单数据 (总订单数, 成交订单数, 成交GMV)
|
||||
type orderStat struct {
|
||||
TrackCode string `gorm:"column:source_track_code"`
|
||||
OrderCount int64 `gorm:"column:order_count"`
|
||||
PaidCount int64 `gorm:"column:paid_order_count"`
|
||||
PaidGMVCent int64 `gorm:"column:paid_gmv_cent"`
|
||||
}
|
||||
var orderStats []orderStat
|
||||
_ = r.db.WithContext(ctx).
|
||||
Table("rental_orders").
|
||||
Select("source_track_code, COUNT(*) as order_count, SUM(CASE WHEN status NOT IN ('pending_payment', 'cancelled', 'closed') THEN 1 ELSE 0 END) as paid_order_count, COALESCE(SUM(CASE WHEN status NOT IN ('pending_payment', 'cancelled', 'closed') THEN rent_amount_cent ELSE 0 END), 0) as paid_gmv_cent").
|
||||
Where("source_track_code IN (?)", codes).
|
||||
Group("source_track_code").
|
||||
Scan(&orderStats).Error
|
||||
|
||||
orderStatMap := make(map[string]orderStat, len(orderStats))
|
||||
for _, os := range orderStats {
|
||||
orderStatMap[os.TrackCode] = os
|
||||
}
|
||||
|
||||
// 组装最终结果
|
||||
items := make([]TrackingLinkItem, 0, len(links))
|
||||
cleanBaseURL := strings.TrimRight(baseURL, "/")
|
||||
for _, l := range links {
|
||||
cs := clickStatMap[l.ID]
|
||||
regCount := userStatMap[l.Code]
|
||||
os := orderStatMap[l.Code]
|
||||
|
||||
var convRate float64
|
||||
if cs.UV > 0 {
|
||||
convRate = float64(os.OrderCount) / float64(cs.UV) * 100.0
|
||||
}
|
||||
|
||||
shortURL := cleanBaseURL + "/r/" + l.Code
|
||||
|
||||
items = append(items, TrackingLinkItem{
|
||||
ID: l.ID,
|
||||
Name: l.Name,
|
||||
Code: l.Code,
|
||||
ChannelType: l.ChannelType,
|
||||
TargetURL: l.TargetURL,
|
||||
ShortURL: shortURL,
|
||||
Status: l.Status,
|
||||
CostCent: l.CostCent,
|
||||
Notes: l.Notes,
|
||||
CreatedBy: l.CreatedBy,
|
||||
CreatedAt: l.CreatedAt,
|
||||
UpdatedAt: l.UpdatedAt,
|
||||
ClickPV: cs.PV,
|
||||
ClickUV: cs.UV,
|
||||
ClickIP: cs.IP,
|
||||
RegisterCount: regCount,
|
||||
OrderCount: os.OrderCount,
|
||||
PaidOrderCount: os.PaidCount,
|
||||
PaidGMVCent: os.PaidGMVCent,
|
||||
ConversionRate: convRate,
|
||||
})
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetOverviewStats(ctx context.Context) (*TrackingOverviewStats, error) {
|
||||
stats := &TrackingOverviewStats{}
|
||||
|
||||
// 链接总数与启用数
|
||||
_ = r.db.WithContext(ctx).Model(&AdTrackingLink{}).Count(&stats.TotalLinks).Error
|
||||
_ = r.db.WithContext(ctx).Model(&AdTrackingLink{}).Where("status = 'active'").Count(&stats.ActiveLinks).Error
|
||||
|
||||
// 累计点击
|
||||
type clickTotal struct {
|
||||
PV int64 `gorm:"column:pv"`
|
||||
UV int64 `gorm:"column:uv"`
|
||||
IPs int64 `gorm:"column:ips"`
|
||||
}
|
||||
var ct clickTotal
|
||||
_ = r.db.WithContext(ctx).
|
||||
Table("ad_tracking_clicks").
|
||||
Select("COUNT(*) as pv, COUNT(DISTINCT visitor_id) as uv, COUNT(DISTINCT ip) as ips").
|
||||
Scan(&ct).Error
|
||||
stats.TotalPV = ct.PV
|
||||
stats.TotalUV = ct.UV
|
||||
stats.TotalIP = ct.IPs
|
||||
|
||||
// 累计注册
|
||||
_ = r.db.WithContext(ctx).
|
||||
Table("users").
|
||||
Where("register_track_code IS NOT NULL AND register_track_code != ''").
|
||||
Count(&stats.TotalRegisters).Error
|
||||
|
||||
// 累计订单与GMV
|
||||
type orderTotal struct {
|
||||
TotalOrders int64 `gorm:"column:total_orders"`
|
||||
TotalPaidOrders int64 `gorm:"column:total_paid_orders"`
|
||||
TotalPaidGMV int64 `gorm:"column:total_paid_gmv"`
|
||||
}
|
||||
var ot orderTotal
|
||||
_ = r.db.WithContext(ctx).
|
||||
Table("rental_orders").
|
||||
Select("COUNT(*) as total_orders, SUM(CASE WHEN status NOT IN ('pending_payment', 'cancelled', 'closed') THEN 1 ELSE 0 END) as total_paid_orders, COALESCE(SUM(CASE WHEN status NOT IN ('pending_payment', 'cancelled', 'closed') THEN rent_amount_cent ELSE 0 END), 0) as total_paid_gmv").
|
||||
Where("source_track_code IS NOT NULL AND source_track_code != ''").
|
||||
Scan(&ot).Error
|
||||
stats.TotalOrders = ot.TotalOrders
|
||||
stats.TotalPaidOrders = ot.TotalPaidOrders
|
||||
stats.TotalPaidGMVCent = ot.TotalPaidGMV
|
||||
|
||||
// 今日数据计算(按本地时区获取今日 00:00:00,避免 UTC 截断产生 8 小时偏差)
|
||||
now := time.Now()
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
|
||||
// 今日点击
|
||||
var todayCt clickTotal
|
||||
_ = r.db.WithContext(ctx).
|
||||
Table("ad_tracking_clicks").
|
||||
Select("COUNT(*) as pv, COUNT(DISTINCT visitor_id) as uv").
|
||||
Where("created_at >= ?", todayStart).
|
||||
Scan(&todayCt).Error
|
||||
stats.TodayPV = todayCt.PV
|
||||
stats.TodayUV = todayCt.UV
|
||||
|
||||
// 今日注册
|
||||
_ = r.db.WithContext(ctx).
|
||||
Table("users").
|
||||
Where("register_track_code IS NOT NULL AND register_track_code != '' AND created_at >= ?", todayStart).
|
||||
Count(&stats.TodayRegisters).Error
|
||||
|
||||
// 今日订单
|
||||
var todayOt orderTotal
|
||||
_ = r.db.WithContext(ctx).
|
||||
Table("rental_orders").
|
||||
Select("COUNT(*) as total_orders, COALESCE(SUM(CASE WHEN status NOT IN ('pending_payment', 'cancelled', 'closed') THEN rent_amount_cent ELSE 0 END), 0) as total_paid_gmv").
|
||||
Where("source_track_code IS NOT NULL AND source_track_code != '' AND created_at >= ?", todayStart).
|
||||
Scan(&todayOt).Error
|
||||
stats.TodayOrders = todayOt.TotalOrders
|
||||
stats.TodayPaidGMVCent = todayOt.TotalPaidGMV
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
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("该后缀为系统保留字,不可使用")
|
||||
ErrDisallowedTargetDomain = errors.New("目标落地页仅允许为 hao858.com 及其子域名或站内路径")
|
||||
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,
|
||||
}
|
||||
|
||||
func validateTargetURL(rawURL string, allowLocal bool) (string, error) {
|
||||
targetURL := strings.TrimSpace(rawURL)
|
||||
if targetURL == "" {
|
||||
return "https://hao858.com/", nil
|
||||
}
|
||||
if strings.HasPrefix(targetURL, "/") {
|
||||
return "https://hao858.com" + targetURL, nil
|
||||
}
|
||||
if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") {
|
||||
targetURL = "https://" + targetURL
|
||||
}
|
||||
u, err := url.Parse(targetURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", errors.New("目标落地页地址不合法")
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return "", errors.New("目标落地页地址不合法")
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if host == "localhost" || host == "127.0.0.1" {
|
||||
if !allowLocal {
|
||||
return "", ErrDisallowedTargetDomain
|
||||
}
|
||||
return targetURL, nil
|
||||
}
|
||||
if host != "hao858.com" && !strings.HasSuffix(host, ".hao858.com") {
|
||||
return "", ErrDisallowedTargetDomain
|
||||
}
|
||||
return targetURL, nil
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
redis *redis.Client
|
||||
allowLocalTargets bool
|
||||
}
|
||||
|
||||
func NewService(repo *Repository, redisClient *redis.Client, allowLocalTargets bool) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
redis: redisClient,
|
||||
allowLocalTargets: allowLocalTargets,
|
||||
}
|
||||
}
|
||||
|
||||
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, err := validateTargetURL(req.TargetURL, s.allowLocalTargets)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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, err := validateTargetURL(req.TargetURL, s.allowLocalTargets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// 保护主进程,防止异步协程 panic
|
||||
}
|
||||
}()
|
||||
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 防刷:同访客 5 秒去重;同 IP 1 秒去重,避免不带 Cookie 轮换 visitorID 刷库
|
||||
if s.redis != nil {
|
||||
if isDuplicateClick(bgCtx, s.redis, "adtrack:dedup:v:"+link.Code+":"+visitorID, 5*time.Second) {
|
||||
return
|
||||
}
|
||||
if isDuplicateClick(bgCtx, s.redis, "adtrack:dedup:ip:"+link.Code+":"+ip, time.Second) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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 isDuplicateClick(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) bool {
|
||||
if rdb == nil || key == "" || strings.HasSuffix(key, ":") {
|
||||
return false
|
||||
}
|
||||
ok, err := rdb.SetNX(ctx, key, "1", ttl).Result()
|
||||
return err == nil && !ok
|
||||
}
|
||||
|
||||
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,131 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTargetURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
allowLocal bool
|
||||
wantURL string
|
||||
wantErr bool
|
||||
errExpected error
|
||||
}{
|
||||
{"", false, "https://hao858.com/", false, nil},
|
||||
{"/listings", false, "https://hao858.com/listings", false, nil},
|
||||
{"https://hao858.com", false, "https://hao858.com", false, nil},
|
||||
{"https://h5.hao858.com/m", false, "https://h5.hao858.com/m", false, nil},
|
||||
{"http://localhost:5189", true, "http://localhost:5189", false, nil},
|
||||
{"http://127.0.0.1:18088", true, "http://127.0.0.1:18088", false, nil},
|
||||
{"http://localhost:5189", false, "", true, ErrDisallowedTargetDomain},
|
||||
{"http://127.0.0.1:18088", false, "", true, ErrDisallowedTargetDomain},
|
||||
{"https://evil-phishing.com", false, "", true, ErrDisallowedTargetDomain},
|
||||
{"https://hao858.com.fake.com", false, "", true, ErrDisallowedTargetDomain},
|
||||
{"https://sub.evil.com/hao858.com", false, "", true, ErrDisallowedTargetDomain},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, err := validateTargetURL(tt.input, tt.allowLocal)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("validateTargetURL(%q, %v) expected error, got nil", tt.input, tt.allowLocal)
|
||||
}
|
||||
if tt.errExpected != nil && err != tt.errExpected {
|
||||
t.Errorf("validateTargetURL(%q, %v) err = %v; want %v", tt.input, tt.allowLocal, err, tt.errExpected)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("validateTargetURL(%q, %v) unexpected error: %v", tt.input, tt.allowLocal, err)
|
||||
}
|
||||
if got != tt.wantURL {
|
||||
t.Errorf("validateTargetURL(%q, %v) = %q; want %q", tt.input, tt.allowLocal, got, tt.wantURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -189,7 +191,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,8 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
@@ -11,6 +13,16 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var trackCodePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||
|
||||
func sanitizeTrackCode(code string) string {
|
||||
s := strings.TrimSpace(code)
|
||||
if len(s) == 0 || len(s) > 64 || !trackCodePattern.MatchString(s) {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type UserRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -57,17 +69,22 @@ 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 = sanitizeTrackCode(trackCode[0])
|
||||
}
|
||||
user := model.User{
|
||||
Phone: phone,
|
||||
Nickname: "用户" + phone[len(phone)-4:],
|
||||
RealnameStatus: "unverified",
|
||||
RiskStatus: "normal",
|
||||
CreditScore: 100,
|
||||
Status: "active",
|
||||
TokenVersion: 1,
|
||||
LastLoginAt: &now,
|
||||
Phone: phone,
|
||||
Nickname: "用户" + phone[len(phone)-4:],
|
||||
RealnameStatus: "unverified",
|
||||
RiskStatus: "normal",
|
||||
CreditScore: 100,
|
||||
Status: "active",
|
||||
RegisterTrackCode: code,
|
||||
TokenVersion: 1,
|
||||
LastLoginAt: &now,
|
||||
}
|
||||
|
||||
err := r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
@@ -106,18 +123,23 @@ func (r *UserRepository) RevokeTokens(ctx context.Context, userID uint64) error
|
||||
UpdateColumn("token_version", gorm.Expr("token_version + 1")).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 = sanitizeTrackCode(trackCode[0])
|
||||
}
|
||||
user := model.User{
|
||||
Phone: phone,
|
||||
PasswordHash: hash,
|
||||
Nickname: "用户" + phone[len(phone)-4:],
|
||||
RealnameStatus: "unverified",
|
||||
RiskStatus: "normal",
|
||||
CreditScore: 100,
|
||||
Status: "active",
|
||||
TokenVersion: 1,
|
||||
LastLoginAt: &now,
|
||||
Phone: phone,
|
||||
PasswordHash: hash,
|
||||
Nickname: "用户" + phone[len(phone)-4:],
|
||||
RealnameStatus: "unverified",
|
||||
RiskStatus: "normal",
|
||||
CreditScore: 100,
|
||||
Status: "active",
|
||||
RegisterTrackCode: code,
|
||||
TokenVersion: 1,
|
||||
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
|
||||
}
|
||||
@@ -349,7 +349,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
|
||||
}
|
||||
@@ -382,7 +382,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
|
||||
|
||||
@@ -81,6 +81,7 @@ type OrderDTO struct {
|
||||
AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"`
|
||||
PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"`
|
||||
DistributionSiteID *uint64 `json:"distribution_site_id,omitempty"`
|
||||
SourceTrackCode string `json:"source_track_code,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -153,6 +154,7 @@ type ActiveDisputeDTO struct {
|
||||
type CreateRequest struct {
|
||||
ListingID uint64 `json:"listing_id" binding:"required"`
|
||||
DistributionSiteCode string `json:"distribution_site_code" binding:"omitempty,max=64"`
|
||||
TrackCode string `json:"track_code,omitempty"`
|
||||
}
|
||||
|
||||
type SubmitHandoffRequest struct {
|
||||
|
||||
@@ -112,6 +112,13 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourceTrackCode := sanitizeTrackCode(req.TrackCode)
|
||||
if sourceTrackCode == "" {
|
||||
var u model.User
|
||||
if err := tx.Select("register_track_code").First(&u, renterID).Error; err == nil && u.RegisterTrackCode != "" {
|
||||
sourceTrackCode = sanitizeTrackCode(u.RegisterTrackCode)
|
||||
}
|
||||
}
|
||||
order := model.RentalOrder{
|
||||
OrderNo: orderNo,
|
||||
ListingID: listing.ID,
|
||||
@@ -150,6 +157,7 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
||||
ManagedAdminID: listing.ManagedAdminID,
|
||||
SettlementStatus: settlementStatusUnsettled,
|
||||
OfflineSettlementStatus: offlineSettlementStatusNone,
|
||||
SourceTrackCode: sourceTrackCode,
|
||||
}
|
||||
if distributionSite != nil && distributionRule != nil {
|
||||
order.DistributionSiteID = &distributionSite.ID
|
||||
|
||||
@@ -135,6 +135,7 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
||||
DepositHoldReleasedAt: row.DepositHoldReleasedAt,
|
||||
AdminActions: adminActionsForOrder(row.RentalOrder),
|
||||
DistributionSiteID: row.DistributionSiteID,
|
||||
SourceTrackCode: row.SourceTrackCode,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -3,13 +3,25 @@ package order
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"hfb_sys/backend/internal/auditlog"
|
||||
"hfb_sys/backend/internal/timeutil"
|
||||
)
|
||||
|
||||
var trackCodePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||
|
||||
func sanitizeTrackCode(code string) string {
|
||||
s := strings.TrimSpace(code)
|
||||
if len(s) == 0 || len(s) > 64 || !trackCodePattern.MatchString(s) {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"hfb_sys/backend/internal/modules/adminpush"
|
||||
"hfb_sys/backend/internal/modules/adminrole"
|
||||
"hfb_sys/backend/internal/modules/adminuser"
|
||||
"hfb_sys/backend/internal/modules/adtracking"
|
||||
"hfb_sys/backend/internal/modules/announcement"
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
"hfb_sys/backend/internal/modules/backupmonitor"
|
||||
@@ -379,6 +380,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, !config.IsProductionEnv(cfg.AppEnv))
|
||||
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
|
||||
@@ -793,6 +801,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;
|
||||
@@ -31,6 +31,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
handle /r/* {
|
||||
reverse_proxy backend:8080 {
|
||||
import backend_proxy
|
||||
}
|
||||
}
|
||||
|
||||
@health path /health /health/ready
|
||||
handle @health {
|
||||
reverse_proxy backend:8080 {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -112,6 +112,7 @@ const resourceLabels: Record<string, string> = {
|
||||
role: '角色管理',
|
||||
system_config: '系统配置',
|
||||
audit_log: '审计日志',
|
||||
ad_tracking: '推广追踪',
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
|
||||
import type { HandoffStatus, OrderStatus, SettlementStatus } from '@/shared/types/status'
|
||||
import { yuanToCent } from '@/shared/utils/money'
|
||||
import { getEffectiveSiteCode } from '@/stores/distributionSite'
|
||||
import { getTrackCode } from '@/shared/utils/trackCode'
|
||||
|
||||
export interface Order {
|
||||
id: number
|
||||
@@ -361,10 +362,18 @@ export async function fetchOrder(id: string | number, distributionSiteCode?: str
|
||||
|
||||
export async function createOrder(listingId: number, distributionSiteCode?: string) {
|
||||
const siteCode = distributionSiteCode || getEffectiveSiteCode() || undefined
|
||||
const payload: { listing_id: number; distribution_site_code?: string } = { listing_id: listingId }
|
||||
const trackCode = getTrackCode()
|
||||
const payload: {
|
||||
listing_id: number
|
||||
distribution_site_code?: string
|
||||
track_code?: string
|
||||
} = { listing_id: listingId }
|
||||
if (siteCode) {
|
||||
payload.distribution_site_code = siteCode
|
||||
}
|
||||
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,
|
||||
@@ -163,6 +165,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',
|
||||
|
||||
@@ -240,4 +240,10 @@ export const adminRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/features/admin/views/AdminDistributionSettlementsView.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,53 @@
|
||||
const TRACK_CODE_KEY = 'hfb_track_code'
|
||||
const TRACK_CODE_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天有效
|
||||
const TRACK_CODE_PATTERN = /^[a-zA-Z0-9_-]{2,64}$/
|
||||
|
||||
interface StoredTrackCode {
|
||||
code: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
function sanitizeTrackCode(code: string): string {
|
||||
const cleanCode = code.trim()
|
||||
if (!TRACK_CODE_PATTERN.test(cleanCode)) return ''
|
||||
return cleanCode
|
||||
}
|
||||
|
||||
export function saveTrackCode(code: string): void {
|
||||
if (!code) return
|
||||
const cleanCode = sanitizeTrackCode(code)
|
||||
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 sanitizeTrackCode(item.code)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTrackCode(): void {
|
||||
try {
|
||||
localStorage.removeItem(TRACK_CODE_KEY)
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ export default defineConfig({
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': apiProxyTarget,
|
||||
'/r': apiProxyTarget,
|
||||
},
|
||||
allowedHosts: [
|
||||
'221329.cc.cd',
|
||||
|
||||
Reference in New Issue
Block a user