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:
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user