From 4ea08e355b8f77596144b044ed0fe531ba91f8cb Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 10 Oct 2026 14:57:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(distribution):=20=E5=88=86=E9=94=80?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E5=A2=9E=E5=8A=A0=E7=94=A8=E6=88=B7=E6=95=B0?= =?UTF-8?q?=E9=87=8F=E7=BB=9F=E8=AE=A1=E4=B8=8E=E7=94=A8=E6=88=B7=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E5=B1=95=E7=A4=BA=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据模型与迁移: 新增 000082 迁移在 users 表追加 distribution_site_id 并建索引,用户首次在分销站点注册/短信登录时自动绑定站点归属 - 分销管理接口: 新增 GET /admin/distribution/users 接口,聚合本站注册用户与下单客户,返回完整11位手机号、累计消费金额、订单数及来源标签 - 统计与大盘扩展: GET /admin/distribution/sites/:id/analytics 补全今日注册、累计用户、成交客户指标,主站分销商列表追加用户体量展示 - 分销后台前端: 新增「客户与用户」页面 (Users.tsx) 支持一键复制手机号与订单穿透,大盘 (Dashboard.tsx) 增加用户规模核心卡片 - 自动化测试与门禁: 新增 TestListDistributionUsersAndAnalytics 单元测试,通过全量 Go 后端集成测试及前后端构建检查 --- backend/internal/model/user.go | 1 + backend/internal/modules/auth/handler.go | 10 +- backend/internal/modules/auth/repository.go | 66 +-- backend/internal/modules/auth/service.go | 8 +- backend/internal/modules/distribution/dto.go | 38 ++ .../internal/modules/distribution/handler.go | 20 + .../modules/distribution/repository.go | 160 +++++++ .../modules/distribution/repository_test.go | 122 +++++ .../internal/modules/distribution/service.go | 7 + backend/internal/router/router.go | 1 + .../000082_user_distribution_site_id.sql | 11 + distribution-admin/src/AppShell.tsx | 4 + distribution-admin/src/api.ts | 52 +++ distribution-admin/src/pages/Dashboard.tsx | 84 ++-- distribution-admin/src/pages/Users.tsx | 418 ++++++++++++++++++ .../features/admin/api/adminDistribution.ts | 2 + .../views/AdminDistributionSitesView.vue | 12 + 17 files changed, 956 insertions(+), 60 deletions(-) create mode 100644 backend/migrations/000082_user_distribution_site_id.sql create mode 100644 distribution-admin/src/pages/Users.tsx diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 8a48f7c..ffc9315 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -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"` diff --git a/backend/internal/modules/auth/handler.go b/backend/internal/modules/auth/handler.go index 948826c..9490473 100644 --- a/backend/internal/modules/auth/handler.go +++ b/backend/internal/modules/auth/handler.go @@ -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) diff --git a/backend/internal/modules/auth/repository.go b/backend/internal/modules/auth/repository.go index 4cebfec..9ca1698 100644 --- a/backend/internal/modules/auth/repository.go +++ b/backend/internal/modules/auth/repository.go @@ -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 diff --git a/backend/internal/modules/auth/service.go b/backend/internal/modules/auth/service.go index 8551ee2..438606b 100644 --- a/backend/internal/modules/auth/service.go +++ b/backend/internal/modules/auth/service.go @@ -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 diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index ab7c6ab..c3a2c40 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -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"` +} diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index cba137b..33c5cd3 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -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 diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 88cbd88..0b8e07c 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -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 +} diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index b9ce63e..c54c0c2 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -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) + } +} diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index 220bb67..9726fde 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -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) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 6dc2a8c..610ddda 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/backend/migrations/000082_user_distribution_site_id.sql b/backend/migrations/000082_user_distribution_site_id.sql new file mode 100644 index 0000000..da8889c --- /dev/null +++ b/backend/migrations/000082_user_distribution_site_id.sql @@ -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; diff --git a/distribution-admin/src/AppShell.tsx b/distribution-admin/src/AppShell.tsx index 9993e44..d47ce18 100644 --- a/distribution-admin/src/AppShell.tsx +++ b/distribution-admin/src/AppShell.tsx @@ -17,6 +17,7 @@ import { UserOutlined, WalletOutlined, GiftOutlined, + TeamOutlined, } from '@ant-design/icons' import { getMe, logout, type CurrentMember, getStoredToken } from './api' import { MemberContext } from './MemberContext' @@ -31,6 +32,7 @@ import Wallet from './pages/Wallet' import Announcements from './pages/Announcements' import SiteSettings from './pages/SiteSettings' import Invitations from './pages/Invitations' +import Users from './pages/Users' import Login from './pages/Login' import Storefront from './pages/Storefront' import './style.css' @@ -44,6 +46,7 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: () ...(member.is_superadmin ? [{ key: '/sites', icon: , label: 分销站点管理 }] : []), + { key: '/users', icon: , label: 客户与用户 }, { key: '/orders', icon: , label: 全部分销订单 }, { key: '/sold', icon: , label: 售出账号订单 }, { key: '/receive', icon: , label: 分销收号管理 }, @@ -169,6 +172,7 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: () } /> } /> } /> + } /> } /> } /> diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index 44b6b7d..71c60a3 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -138,6 +138,8 @@ export interface Site { admin_username?: string admin_nickname?: string member_type?: string + user_count?: number + order_user_count?: number created_at: string } @@ -820,6 +822,9 @@ export interface DistributionAnalytics { today_profit_cent: number month_profit_cent: number available_balance_cent: number + today_user_count?: number + total_user_count?: number + total_order_user_count?: number trend_days: { date: string received_count: number @@ -834,6 +839,53 @@ export async function fetchDashboardAnalytics(siteID: number): Promise { + const resp = await api.get>('/admin/distribution/users', { + params: { + site_id: params.siteID, + keyword: params.keyword, + source: params.source, + page: params.page, + page_size: params.pageSize, + }, + }) + return extract(resp) +} + export interface SiteSMSConfig { enabled: boolean provider: string diff --git a/distribution-admin/src/pages/Dashboard.tsx b/distribution-admin/src/pages/Dashboard.tsx index ba2fea9..6325ac9 100644 --- a/distribution-admin/src/pages/Dashboard.tsx +++ b/distribution-admin/src/pages/Dashboard.tsx @@ -20,6 +20,8 @@ import { LineChartOutlined, ArrowRightOutlined, SkinOutlined, + TeamOutlined, + ShoppingOutlined, } from '@ant-design/icons' import { Link } from 'react-router-dom' import { @@ -197,34 +199,35 @@ export default function Dashboard() { {/* 核心业务指标看板 */} - - + + -
- 查看收号资产 -
-
- - - - } - suffix="个" + title="今日注册 / 累计用户" + value={analytics?.today_user_count ?? 0} + suffix={`/ ${analytics?.total_user_count ?? 0} 人`} + prefix={} valueStyle={{ color: '#52c41a', fontWeight: 'bold' }} />
- 管理商品货架 + 查看客户列表
- + + + } + valueStyle={{ color: '#fa8c16', fontWeight: 'bold' }} + /> +
+ 成交客户分析 +
+
+ + - + - + + + +
+ 查看收号资产 +
+
+ + + + } + suffix="个" + valueStyle={{ color: '#52c41a', fontWeight: 'bold' }} + /> +
+ 管理商品货架 +
+
+ + - - + + } + prefix={} suffix="元" - valueStyle={{ color: '#52c41a', fontWeight: 'bold' }} + valueStyle={{ color: '#1677ff', fontWeight: 'bold' }} />
立即提现 diff --git a/distribution-admin/src/pages/Users.tsx b/distribution-admin/src/pages/Users.tsx new file mode 100644 index 0000000..4879568 --- /dev/null +++ b/distribution-admin/src/pages/Users.tsx @@ -0,0 +1,418 @@ +import { useEffect, useState, useCallback } from 'react' +import { + Card, + Table, + Typography, + Space, + Input, + Select, + Tag, + Button, + Avatar, + Row, + Col, + Statistic, + message, + Tooltip, +} from 'antd' +import { + UserOutlined, + SearchOutlined, + ReloadOutlined, + CopyOutlined, + CheckOutlined, + ShoppingOutlined, + TeamOutlined, + CheckCircleOutlined, + ClockCircleOutlined, +} from '@ant-design/icons' +import { useNavigate } from 'react-router-dom' +import { + listDistributionUsers, + listSites, + formatMoney, + type DistributionUser, + type Site, +} from '../api' +import { useMember } from '../MemberContext' + +function formatDateTime(str?: string): string { + if (!str) return '-' + try { + const d = new Date(str) + if (isNaN(d.getTime())) return str + const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`) + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` + } catch { + return str + } +} + +export default function Users() { + const member = useMember() + const navigate = useNavigate() + + const [sites, setSites] = useState([]) + const [siteID, setSiteID] = useState(member?.site_id) + const [users, setUsers] = useState([]) + const [total, setTotal] = useState(0) + const [totalUsers, setTotalUsers] = useState(0) + const [orderUsers, setOrderUsers] = useState(0) + const [loading, setLoading] = useState(false) + + // 筛选条件 + const [keyword, setKeyword] = useState('') + const [source, setSource] = useState<'all' | 'register' | 'order'>('all') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + // 复制状态记录 + const [copiedPhone, setCopiedPhone] = useState(null) + + const copyToClipboard = (text: string) => { + navigator.clipboard?.writeText(text) + setCopiedPhone(text) + message.success(`已复制手机号:${text}`) + setTimeout(() => { + setCopiedPhone(null) + }, 2000) + } + + const loadData = useCallback( + async (curSiteID?: number, curPage = page, curSize = pageSize) => { + setLoading(true) + try { + const res = await listDistributionUsers({ + siteID: curSiteID, + keyword: keyword.trim() || undefined, + source, + page: curPage, + pageSize: curSize, + }) + setUsers(res.items || []) + setTotal(res.total || 0) + setTotalUsers(res.total_users || 0) + setOrderUsers(res.order_users || 0) + } catch { + message.error('加载用户列表失败') + } finally { + setLoading(false) + } + }, + [keyword, source, page, pageSize] + ) + + useEffect(() => { + async function init() { + if (member?.is_superadmin) { + try { + const rows = await listSites() + setSites(rows) + if (!siteID && rows.length > 0) { + setSiteID(rows[0].id) + void loadData(rows[0].id, 1, pageSize) + return + } + } catch { + // ignore + } + } + void loadData(siteID, 1, pageSize) + } + void init() + }, [member]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleSearch = () => { + setPage(1) + void loadData(siteID, 1, pageSize) + } + + const handleReset = () => { + setKeyword('') + setSource('all') + setPage(1) + setTimeout(() => { + void loadData(siteID, 1, pageSize) + }, 0) + } + + const columns = [ + { + title: '用户 ID', + dataIndex: 'id', + key: 'id', + width: 90, + render: (id: number) => ( + + #{id} + + ), + }, + { + title: '用户手机号', + dataIndex: 'phone', + key: 'phone', + width: 170, + render: (phone: string) => ( + + + {phone} + + + + + ) + } + return 0 单 + }, + }, + { + title: '本站累计消费', + dataIndex: 'total_spent_cent', + key: 'total_spent_cent', + width: 130, + render: (cent: number) => { + if (cent > 0) { + return ( + + {formatMoney(cent)} + + ) + } + return ¥0.00 + }, + }, + { + title: '实名状态', + dataIndex: 'realname_status', + key: 'realname_status', + width: 110, + render: (st: string) => { + if (st === 'verified') { + return ( + } color="success"> + 已实名 + + ) + } + return ( + } color="default"> + 未实名 + + ) + }, + }, + { + title: '注册时间', + dataIndex: 'created_at', + key: 'created_at', + width: 170, + render: (t: string) => ( + {formatDateTime(t)} + ), + }, + { + title: '最后登录', + dataIndex: 'last_login_at', + key: 'last_login_at', + width: 170, + render: (t?: string) => ( + {formatDateTime(t)} + ), + }, + ] + + const selectedSiteName = + member?.site_name || sites.find((s) => s.id === siteID)?.site_name || '当前分销商' + + return ( + +
+
+ + 客户与用户管理 + + + 当前站点:{selectedSiteName} · 查看在本分销站注册的专属用户以及在本站产生交易的客户 + +
+ + {member?.is_superadmin && sites.length > 1 && ( + setKeyword(e.target.value)} + onPressEnter={handleSearch} + prefix={} + style={{ width: 240 }} + allowClear + /> +