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
@@ -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)
}
}