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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user