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:
yml2213
2026-10-10 14:58:33 +08:00
parent 6d34f86765
commit 4ea08e355b
17 changed files with 956 additions and 60 deletions
@@ -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
}