feat(distribution): 分销站点增加用户数量统计与用户信息展示模块
- 数据模型与迁移: 新增 000082 迁移在 users 表追加 distribution_site_id 并建索引,用户首次在分销站点注册/短信登录时自动绑定站点归属 - 分销管理接口: 新增 GET /admin/distribution/users 接口,聚合本站注册用户与下单客户,返回完整11位手机号、累计消费金额、订单数及来源标签 - 统计与大盘扩展: GET /admin/distribution/sites/:id/analytics 补全今日注册、累计用户、成交客户指标,主站分销商列表追加用户体量展示 - 分销后台前端: 新增「客户与用户」页面 (Users.tsx) 支持一键复制手机号与订单穿透,大盘 (Dashboard.tsx) 增加用户规模核心卡片 - 自动化测试与门禁: 新增 TestListDistributionUsersAndAnalytics 单元测试,通过全量 Go 后端集成测试及前后端构建检查
This commit is contained in:
@@ -20,6 +20,7 @@ type User struct {
|
||||
TokenVersion int64 `gorm:"not null;default:0" json:"-"`
|
||||
// 注册归因:注册时由前端携带的推广短码。
|
||||
RegisterTrackCode string `gorm:"size:64;index:idx_users_register_track_code" json:"register_track_code,omitempty"`
|
||||
DistributionSiteID uint64 `gorm:"not null;default:0;index:idx_users_distribution_site_id" json:"distribution_site_id"` // 0=主站, >0=对应分销站点
|
||||
InviteCode *string `gorm:"size:32;uniqueIndex:uk_users_invite_code" json:"invite_code,omitempty"`
|
||||
InvitedByID uint64 `gorm:"not null;default:0;index:idx_users_invited_by_id" json:"invited_by_id,omitempty"`
|
||||
InviteVerifiedAt *time.Time `json:"invite_verified_at,omitempty"`
|
||||
|
||||
@@ -148,13 +148,14 @@ 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), strings.TrimSpace(req.TrackCode))
|
||||
siteCode := sitecode.FromRequest(c)
|
||||
result, err := h.service.LoginWithSMS(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), strings.TrimSpace(req.TrackCode), siteCode)
|
||||
if err != nil {
|
||||
writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
if h.invitationBinder != nil && strings.TrimSpace(req.InviteCode) != "" {
|
||||
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, sitecode.FromRequest(c))
|
||||
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, siteCode)
|
||||
}
|
||||
setAccessCookie(c, result.Tokens.AccessToken)
|
||||
response.OK(c, result)
|
||||
@@ -234,13 +235,14 @@ 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, strings.TrimSpace(req.TrackCode))
|
||||
siteCode := sitecode.FromRequest(c)
|
||||
result, err := h.service.RegisterWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.Password, strings.TrimSpace(req.TrackCode), siteCode)
|
||||
if err != nil {
|
||||
writeAuthError(c, err)
|
||||
return
|
||||
}
|
||||
if h.invitationBinder != nil && strings.TrimSpace(req.InviteCode) != "" {
|
||||
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, sitecode.FromRequest(c))
|
||||
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, siteCode)
|
||||
}
|
||||
setAccessCookie(c, result.Tokens.AccessToken)
|
||||
response.OK(c, result)
|
||||
|
||||
@@ -69,21 +69,35 @@ func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname
|
||||
return r.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string, trackCode ...string) (*model.User, error) {
|
||||
func (r *UserRepository) resolveSiteID(ctx context.Context, siteCode string) uint64 {
|
||||
siteCode = strings.TrimSpace(siteCode)
|
||||
if siteCode == "" {
|
||||
return 0
|
||||
}
|
||||
var ds model.DistributionSite
|
||||
if err := r.db.WithContext(ctx).Where("site_code = ? AND status = 'active'", siteCode).Select("id").First(&ds).Error; err == nil {
|
||||
return ds.ID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string, trackCode string, siteCode ...string) (*model.User, error) {
|
||||
now := time.Now()
|
||||
var code string
|
||||
if len(trackCode) > 0 {
|
||||
code = sanitizeTrackCode(trackCode[0])
|
||||
code := sanitizeTrackCode(trackCode)
|
||||
var siteID uint64
|
||||
if len(siteCode) > 0 {
|
||||
siteID = r.resolveSiteID(ctx, siteCode[0])
|
||||
}
|
||||
user := model.User{
|
||||
Phone: phone,
|
||||
Nickname: "用户" + phone[len(phone)-4:],
|
||||
RealnameStatus: "unverified",
|
||||
RiskStatus: "normal",
|
||||
CreditScore: 100,
|
||||
Status: "active",
|
||||
RegisterTrackCode: code,
|
||||
LastLoginAt: &now,
|
||||
Phone: phone,
|
||||
Nickname: "用户" + phone[len(phone)-4:],
|
||||
RealnameStatus: "unverified",
|
||||
RiskStatus: "normal",
|
||||
CreditScore: 100,
|
||||
Status: "active",
|
||||
RegisterTrackCode: code,
|
||||
DistributionSiteID: siteID,
|
||||
LastLoginAt: &now,
|
||||
}
|
||||
|
||||
err := r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
@@ -122,22 +136,24 @@ 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, trackCode ...string) (*model.User, error) {
|
||||
func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string, trackCode string, siteCode ...string) (*model.User, error) {
|
||||
now := time.Now()
|
||||
var code string
|
||||
if len(trackCode) > 0 {
|
||||
code = sanitizeTrackCode(trackCode[0])
|
||||
code := sanitizeTrackCode(trackCode)
|
||||
var siteID uint64
|
||||
if len(siteCode) > 0 {
|
||||
siteID = r.resolveSiteID(ctx, siteCode[0])
|
||||
}
|
||||
user := model.User{
|
||||
Phone: phone,
|
||||
PasswordHash: hash,
|
||||
Nickname: "用户" + phone[len(phone)-4:],
|
||||
RealnameStatus: "unverified",
|
||||
RiskStatus: "normal",
|
||||
CreditScore: 100,
|
||||
Status: "active",
|
||||
RegisterTrackCode: code,
|
||||
LastLoginAt: &now,
|
||||
Phone: phone,
|
||||
PasswordHash: hash,
|
||||
Nickname: "用户" + phone[len(phone)-4:],
|
||||
RealnameStatus: "unverified",
|
||||
RiskStatus: "normal",
|
||||
CreditScore: 100,
|
||||
Status: "active",
|
||||
RegisterTrackCode: code,
|
||||
DistributionSiteID: siteID,
|
||||
LastLoginAt: &now,
|
||||
}
|
||||
|
||||
err := r.db.WithContext(ctx).Create(&user).Error
|
||||
|
||||
@@ -193,7 +193,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, trackCode ...string) (LoginResult, error) {
|
||||
func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string, trackCode string, siteCode ...string) (LoginResult, error) {
|
||||
if s.redis == nil || s.users == nil {
|
||||
return LoginResult{}, ErrDependencyUnavailable
|
||||
}
|
||||
@@ -209,7 +209,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string, t
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
user, err := s.users.FindOrCreateByPhone(ctx, phone, trackCode...)
|
||||
user, err := s.users.FindOrCreateByPhone(ctx, phone, trackCode, siteCode...)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
@@ -385,7 +385,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, trackCode ...string) (LoginResult, error) {
|
||||
func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, password string, trackCode string, siteCode ...string) (LoginResult, error) {
|
||||
if s.redis == nil || s.users == nil {
|
||||
return LoginResult{}, ErrDependencyUnavailable
|
||||
}
|
||||
@@ -418,7 +418,7 @@ func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, passwor
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
user, err := s.users.RegisterWithPassword(ctx, phone, string(hash), trackCode...)
|
||||
user, err := s.users.RegisterWithPassword(ctx, phone, string(hash), trackCode, siteCode...)
|
||||
if err != nil {
|
||||
// 并发场景下仍可能因唯一索引冲突,统一按"已注册"处理
|
||||
return LoginResult{}, ErrUserAlreadyExists
|
||||
|
||||
@@ -29,6 +29,8 @@ type SiteDTO struct {
|
||||
AdminUsername string `json:"admin_username,omitempty"`
|
||||
AdminNickname string `json:"admin_nickname,omitempty"`
|
||||
MemberType string `json:"member_type,omitempty"`
|
||||
UserCount int64 `json:"user_count"`
|
||||
OrderUserCount int64 `json:"order_user_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -575,6 +577,9 @@ type DistributionAnalyticsDTO struct {
|
||||
TodayProfitCent int64 `json:"today_profit_cent"`
|
||||
MonthProfitCent int64 `json:"month_profit_cent"`
|
||||
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
||||
TodayUserCount int64 `json:"today_user_count"`
|
||||
TotalUserCount int64 `json:"total_user_count"`
|
||||
TotalOrderUserCount int64 `json:"total_order_user_count"`
|
||||
TrendDays []DistributionTrendPoint `json:"trend_days"`
|
||||
}
|
||||
|
||||
@@ -618,3 +623,36 @@ type TestSiteSMSConfigRequest struct {
|
||||
SignName *string `json:"sign_name"`
|
||||
LoginTemplateCode *string `json:"login_template_code"`
|
||||
}
|
||||
|
||||
// 分销站点客户/用户信息 DTO
|
||||
type DistributionUserDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
Phone string `json:"phone"`
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
RealnameStatus string `json:"realname_status"`
|
||||
Status string `json:"status"`
|
||||
Source string `json:"source"` // register=本站注册, order=本站下单, both=注册并下单
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalSpentCent int64 `json:"total_spent_cent"`
|
||||
LastOrderAt *time.Time `json:"last_order_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
}
|
||||
|
||||
type DistributionUserPageDTO struct {
|
||||
Items []DistributionUserDTO `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalUsers int64 `json:"total_users"`
|
||||
OrderUsers int64 `json:"order_users"`
|
||||
}
|
||||
|
||||
type ListDistributionUsersQuery struct {
|
||||
SiteID uint64 `form:"site_id"`
|
||||
Keyword string `form:"keyword"`
|
||||
Source string `form:"source"` // all / register / order
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=20"`
|
||||
}
|
||||
|
||||
@@ -986,6 +986,26 @@ func (h *Handler) GetDashboardAnalytics(c *gin.Context) {
|
||||
response.OK(c, analytics)
|
||||
}
|
||||
|
||||
func (h *Handler) ListDistributionUsers(c *gin.Context) {
|
||||
siteID, err := h.resolveSiteID(c)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
adminID, _ := currentAdminID(c)
|
||||
var query ListDistributionUsersQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.BadRequest(c, "查询参数格式错误")
|
||||
return
|
||||
}
|
||||
resp, err := h.service.ListDistributionUsers(c.Request.Context(), adminID, siteID, query)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, resp)
|
||||
}
|
||||
|
||||
func isHTTPSRequest(c *gin.Context) bool {
|
||||
if c.Request.TLS != nil {
|
||||
return true
|
||||
|
||||
@@ -314,6 +314,34 @@ func (r *Repository) ListSites(ctx context.Context) ([]SiteDTO, error) {
|
||||
}
|
||||
}
|
||||
|
||||
type countRow struct {
|
||||
SiteID uint64 `gorm:"column:distribution_site_id"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
var userCounts []countRow
|
||||
_ = r.db.WithContext(ctx).Table("users").
|
||||
Select("distribution_site_id, COUNT(*) AS cnt").
|
||||
Where("distribution_site_id IN ?", siteIDs).
|
||||
Group("distribution_site_id").Scan(&userCounts).Error
|
||||
userCountMap := make(map[uint64]int64, len(userCounts))
|
||||
for _, c := range userCounts {
|
||||
userCountMap[c.SiteID] = c.Cnt
|
||||
}
|
||||
|
||||
type orderCountRow struct {
|
||||
SiteID uint64 `gorm:"column:distribution_site_id"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
var orderUserCounts []orderCountRow
|
||||
_ = r.db.WithContext(ctx).Table("rental_orders").
|
||||
Select("distribution_site_id, COUNT(DISTINCT renter_id) AS cnt").
|
||||
Where("distribution_site_id IN ? AND status NOT IN ('cancelled', 'closed')", siteIDs).
|
||||
Group("distribution_site_id").Scan(&orderUserCounts).Error
|
||||
orderCountMap := make(map[uint64]int64, len(orderUserCounts))
|
||||
for _, c := range orderUserCounts {
|
||||
orderCountMap[c.SiteID] = c.Cnt
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
if m, ok := memberMap[items[i].ID]; ok {
|
||||
items[i].AdminUserID = &m.AdminUserID
|
||||
@@ -321,6 +349,8 @@ func (r *Repository) ListSites(ctx context.Context) ([]SiteDTO, error) {
|
||||
items[i].AdminNickname = m.Nickname
|
||||
items[i].MemberType = m.MemberType
|
||||
}
|
||||
items[i].UserCount = userCountMap[items[i].ID]
|
||||
items[i].OrderUserCount = orderCountMap[items[i].ID]
|
||||
}
|
||||
|
||||
return items, nil
|
||||
@@ -3344,6 +3374,19 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID
|
||||
Where("(site_id = ? OR party_id = ?) AND party_type = 'site' AND created_at >= ?", siteID, siteID, monthStr+"-01 00:00:00").
|
||||
Select("COALESCE(SUM(profit_amount_cent), 0)").Scan(&monthProfit).Error
|
||||
|
||||
var todayUserCount int64
|
||||
_ = r.db.WithContext(ctx).Model(&model.User{}).
|
||||
Where("distribution_site_id = ? AND created_at >= ?", siteID, todayStr+" 00:00:00").Count(&todayUserCount).Error
|
||||
|
||||
var totalUserCount int64
|
||||
_ = r.db.WithContext(ctx).Model(&model.User{}).
|
||||
Where("distribution_site_id = ?", siteID).Count(&totalUserCount).Error
|
||||
|
||||
var totalOrderUserCount int64
|
||||
_ = r.db.WithContext(ctx).Table("rental_orders").
|
||||
Where("(distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = rental_orders.id AND (p.site_id = ? OR p.party_id = ?))) AND status NOT IN ('cancelled', 'closed')", siteID, siteID, siteID).
|
||||
Distinct("renter_id").Count(&totalOrderUserCount).Error
|
||||
|
||||
var wallet model.WalletAccount
|
||||
_ = r.db.WithContext(ctx).Where("user_id = 0 AND distribution_site_id = ?", siteID).First(&wallet).Error
|
||||
|
||||
@@ -3422,6 +3465,9 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID
|
||||
TodayProfitCent: todayProfit,
|
||||
MonthProfitCent: monthProfit,
|
||||
AvailableBalanceCent: wallet.AvailableBalanceCent,
|
||||
TodayUserCount: todayUserCount,
|
||||
TotalUserCount: totalUserCount,
|
||||
TotalOrderUserCount: totalOrderUserCount,
|
||||
TrendDays: trendDays,
|
||||
}
|
||||
if r.redis != nil {
|
||||
@@ -3431,3 +3477,117 @@ func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID
|
||||
}
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListDistributionUsers(ctx context.Context, adminID, siteID uint64, query ListDistributionUsersQuery) (*DistributionUserPageDTO, error) {
|
||||
if siteID == 0 {
|
||||
return nil, ErrSiteNotFound
|
||||
}
|
||||
if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page := query.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := query.PageSize
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// 统计全站总注册人数与成交客户数
|
||||
var totalUsersCount int64
|
||||
var orderUsersCount int64
|
||||
|
||||
_ = r.db.WithContext(ctx).Model(&model.User{}).
|
||||
Where("distribution_site_id = ?", siteID).Count(&totalUsersCount).Error
|
||||
|
||||
_ = r.db.WithContext(ctx).Table("rental_orders").
|
||||
Where("(distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = rental_orders.id AND (p.site_id = ? OR p.party_id = ?))) AND status NOT IN ('cancelled', 'closed')", siteID, siteID, siteID).
|
||||
Distinct("renter_id").Count(&orderUsersCount).Error
|
||||
|
||||
orderSubquery := r.db.Table("rental_orders").
|
||||
Select("renter_id, COUNT(*) AS ord_count, COALESCE(SUM(rent_amount_cent), 0) AS ord_spent, MAX(created_at) AS last_ord_at").
|
||||
Where("(distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = rental_orders.id AND (p.site_id = ? OR p.party_id = ?))) AND status NOT IN ('cancelled', 'closed')", siteID, siteID, siteID).
|
||||
Group("renter_id")
|
||||
|
||||
baseQuery := r.db.WithContext(ctx).Table("users AS u").
|
||||
Joins("LEFT JOIN (?) AS ord ON ord.renter_id = u.id", orderSubquery)
|
||||
|
||||
switch strings.TrimSpace(query.Source) {
|
||||
case "register":
|
||||
baseQuery = baseQuery.Where("u.distribution_site_id = ?", siteID)
|
||||
case "order":
|
||||
baseQuery = baseQuery.Where("ord.renter_id IS NOT NULL")
|
||||
default:
|
||||
baseQuery = baseQuery.Where("u.distribution_site_id = ? OR ord.renter_id IS NOT NULL", siteID)
|
||||
}
|
||||
|
||||
if kw := strings.TrimSpace(query.Keyword); kw != "" {
|
||||
baseQuery = baseQuery.Where("u.phone LIKE ? OR u.nickname LIKE ?", "%"+kw+"%", "%"+kw+"%")
|
||||
}
|
||||
|
||||
var totalFiltered int64
|
||||
if err := baseQuery.Count(&totalFiltered).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type userRow struct {
|
||||
ID uint64 `gorm:"column:id"`
|
||||
Phone string `gorm:"column:phone"`
|
||||
Nickname string `gorm:"column:nickname"`
|
||||
AvatarURL string `gorm:"column:avatar_url"`
|
||||
RealnameStatus string `gorm:"column:realname_status"`
|
||||
Status string `gorm:"column:status"`
|
||||
DistributionSiteID uint64 `gorm:"column:distribution_site_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
LastLoginAt *time.Time `gorm:"column:last_login_at"`
|
||||
OrderCount int64 `gorm:"column:ord_count"`
|
||||
TotalSpentCent int64 `gorm:"column:ord_spent"`
|
||||
LastOrderAt *time.Time `gorm:"column:last_ord_at"`
|
||||
}
|
||||
|
||||
var rows []userRow
|
||||
if err := baseQuery.
|
||||
Select("u.id, u.phone, u.nickname, u.avatar_url, u.realname_status, u.status, u.distribution_site_id, u.created_at, u.last_login_at, COALESCE(ord.ord_count, 0) AS ord_count, COALESCE(ord.ord_spent, 0) AS ord_spent, ord.last_ord_at").
|
||||
Order("COALESCE(ord.last_ord_at, u.created_at) DESC").
|
||||
Offset(offset).Limit(pageSize).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]DistributionUserDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
source := "register"
|
||||
if row.DistributionSiteID == siteID && row.OrderCount > 0 {
|
||||
source = "both"
|
||||
} else if row.DistributionSiteID != siteID && row.OrderCount > 0 {
|
||||
source = "order"
|
||||
}
|
||||
|
||||
items = append(items, DistributionUserDTO{
|
||||
ID: row.ID,
|
||||
Phone: row.Phone,
|
||||
Nickname: row.Nickname,
|
||||
AvatarURL: row.AvatarURL,
|
||||
RealnameStatus: row.RealnameStatus,
|
||||
Status: row.Status,
|
||||
Source: source,
|
||||
OrderCount: row.OrderCount,
|
||||
TotalSpentCent: row.TotalSpentCent,
|
||||
LastOrderAt: row.LastOrderAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
LastLoginAt: row.LastLoginAt,
|
||||
})
|
||||
}
|
||||
|
||||
return &DistributionUserPageDTO{
|
||||
Items: items,
|
||||
Total: totalFiltered,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalUsers: totalUsersCount,
|
||||
OrderUsers: orderUsersCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1518,3 +1518,125 @@ func TestBatchOperationsAndCachingOptimizations(t *testing.T) {
|
||||
t.Fatalf("paged keyword mismatch: total=%d, items=%+v", pagedResult.Total, pagedResult.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDistributionUsersAndAnalytics(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
|
||||
site := model.DistributionSite{SiteCode: "user_test_site", SiteName: "用户测试站", Domain: "users.example.com", Status: "active"}
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
otherSite := model.DistributionSite{SiteCode: "other_site", SiteName: "其他站", Domain: "other.example.com", Status: "active"}
|
||||
if err := db.Create(&otherSite).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// 用户 1: 本站注册,未下单
|
||||
u1 := model.User{Phone: "13800000001", Nickname: "用户一", DistributionSiteID: site.ID, Status: "active", CreatedAt: now}
|
||||
// 用户 2: 本站注册,且在本站下过单
|
||||
u2 := model.User{Phone: "13800000002", Nickname: "用户二", DistributionSiteID: site.ID, Status: "active", CreatedAt: now}
|
||||
// 用户 3: 在主站注册 (DistributionSiteID=0),但在本站下过单
|
||||
u3 := model.User{Phone: "13800000003", Nickname: "用户三", DistributionSiteID: 0, Status: "active", CreatedAt: now}
|
||||
// 用户 4: 在其他站注册 (DistributionSiteID=otherSite.ID),未在本站下过单
|
||||
u4 := model.User{Phone: "13800000004", Nickname: "用户四", DistributionSiteID: otherSite.ID, Status: "active", CreatedAt: now}
|
||||
|
||||
if err := db.Create(&u1).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&u2).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&u3).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&u4).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 订单 1: 用户 2 在本站下单
|
||||
o1 := model.RentalOrder{
|
||||
OrderNo: "ORD-U2", ListingID: 101, AccountID: 201, OwnerID: 99, RenterID: u2.ID,
|
||||
DistributionSiteID: &site.ID, RentAmountCent: 5000, Status: "completed", CreatedAt: now,
|
||||
}
|
||||
// 订单 2: 用户 3 在本站下单
|
||||
o2 := model.RentalOrder{
|
||||
OrderNo: "ORD-U3", ListingID: 102, AccountID: 202, OwnerID: 99, RenterID: u3.ID,
|
||||
DistributionSiteID: &site.ID, RentAmountCent: 3000, Status: "completed", CreatedAt: now,
|
||||
}
|
||||
// 订单 3: 用户 4 在其他站下单
|
||||
o3 := model.RentalOrder{
|
||||
OrderNo: "ORD-U4", ListingID: 103, AccountID: 203, OwnerID: 99, RenterID: u4.ID,
|
||||
DistributionSiteID: &otherSite.ID, RentAmountCent: 8000, Status: "completed", CreatedAt: now,
|
||||
}
|
||||
if err := db.Create(&o1).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&o2).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&o3).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 1. 查询全部用户 (应该包含 u1, u2, u3;不包含 u4)
|
||||
resAll, err := repo.ListDistributionUsers(t.Context(), 0, site.ID, ListDistributionUsersQuery{Page: 1, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListDistributionUsers all failed: %v", err)
|
||||
}
|
||||
if resAll.Total != 3 {
|
||||
t.Fatalf("expected 3 total users, got %d", resAll.Total)
|
||||
}
|
||||
if resAll.TotalUsers != 2 { // u1, u2 本站注册
|
||||
t.Fatalf("expected 2 total registered users, got %d", resAll.TotalUsers)
|
||||
}
|
||||
if resAll.OrderUsers != 2 { // u2, u3 本站下单
|
||||
t.Fatalf("expected 2 order users, got %d", resAll.OrderUsers)
|
||||
}
|
||||
|
||||
// 验证手机号完整输出
|
||||
for _, it := range resAll.Items {
|
||||
if len(it.Phone) != 11 {
|
||||
t.Fatalf("expected full 11-digit phone, got %s", it.Phone)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 筛选 source=register
|
||||
resReg, err := repo.ListDistributionUsers(t.Context(), 0, site.ID, ListDistributionUsersQuery{Source: "register", Page: 1, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListDistributionUsers register failed: %v", err)
|
||||
}
|
||||
if resReg.Total != 2 {
|
||||
t.Fatalf("expected 2 register users, got %d", resReg.Total)
|
||||
}
|
||||
|
||||
// 3. 筛选 source=order
|
||||
resOrd, err := repo.ListDistributionUsers(t.Context(), 0, site.ID, ListDistributionUsersQuery{Source: "order", Page: 1, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListDistributionUsers order failed: %v", err)
|
||||
}
|
||||
if resOrd.Total != 2 {
|
||||
t.Fatalf("expected 2 order users, got %d", resOrd.Total)
|
||||
}
|
||||
|
||||
// 4. 关键字搜索手机号
|
||||
resKw, err := repo.ListDistributionUsers(t.Context(), 0, site.ID, ListDistributionUsersQuery{Keyword: "13800000001", Page: 1, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListDistributionUsers kw failed: %v", err)
|
||||
}
|
||||
if resKw.Total != 1 || resKw.Items[0].ID != u1.ID {
|
||||
t.Fatalf("keyword search failed: %+v", resKw)
|
||||
}
|
||||
|
||||
// 5. 验证大盘用户统计
|
||||
analytics, err := repo.GetDashboardAnalytics(t.Context(), 0, site.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDashboardAnalytics failed: %v", err)
|
||||
}
|
||||
if analytics.TodayUserCount != 2 || analytics.TotalUserCount != 2 || analytics.TotalOrderUserCount != 2 {
|
||||
t.Fatalf("analytics user count mismatch: today=%d, total=%d, order=%d",
|
||||
analytics.TodayUserCount, analytics.TotalUserCount, analytics.TotalOrderUserCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,3 +481,10 @@ func (s *Service) TestSiteSMSConfig(ctx context.Context, adminID, siteID uint64,
|
||||
|
||||
return s.smsResolver.TestSendSMS(ctx, siteID, testCfg, phone)
|
||||
}
|
||||
|
||||
func (s *Service) ListDistributionUsers(ctx context.Context, adminID, siteID uint64, query ListDistributionUsersQuery) (*DistributionUserPageDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListDistributionUsers(ctx, adminID, siteID, query)
|
||||
}
|
||||
|
||||
@@ -735,6 +735,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.POST("/distribution/sites/:id/qrcodes/batch-delete", requirePerm("qrcode:manage"), chatHandler.BatchDeleteSiteQrCodeHandler)
|
||||
adminRoutes.PATCH("/distribution/sites/:id/qrcodes/:qrcodeId", requirePerm("qrcode:manage"), chatHandler.UpdateSiteQrCodeHandler)
|
||||
adminRoutes.DELETE("/distribution/sites/:id/qrcodes/:qrcodeId", requirePerm("qrcode:manage"), chatHandler.DeleteSiteQrCodeHandler)
|
||||
adminRoutes.GET("/distribution/users", requirePerm("distribution:site_view"), distributionHandler.ListDistributionUsers)
|
||||
adminRoutes.GET("/distribution/received-listings", requirePerm("distribution:account_view"), distributionHandler.ListReceivedListings)
|
||||
adminRoutes.PUT("/distribution/received-listings/:id/status", requirePerm("distribution:account_display"), distributionHandler.UpdateReceivedListingStatus)
|
||||
adminRoutes.GET("/distribution/announcements", requirePerm("distribution:announcement_view"), distributionHandler.ListSiteAnnouncements)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN distribution_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '归属分销站点ID (0=主站)' AFTER register_track_code,
|
||||
ADD KEY idx_users_distribution_site_id (distribution_site_id);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE users
|
||||
DROP KEY idx_users_distribution_site_id,
|
||||
DROP COLUMN distribution_site_id;
|
||||
Reference in New Issue
Block a user