From 51855a0e1727ac00cef4f2a6f8e7708dde4651cd Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 19 Oct 2026 17:52:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=82=80=E8=AF=B7=E5=85=B3=E7=B3=BB?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=A2=9E=E5=8A=A0=E9=82=80=E8=AF=B7=E4=BA=BA?= =?UTF-8?q?=E7=B4=AF=E8=AE=A1=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/invitation/admin_filters_test.go | 104 ++++++++++++++++++ .../internal/modules/invitation/repository.go | 72 ++++++++++-- .../features/admin/api/adminInvitations.ts | 8 ++ .../admin/views/AdminInvitationsView.vue | 43 +++++++- 4 files changed, 214 insertions(+), 13 deletions(-) diff --git a/backend/internal/modules/invitation/admin_filters_test.go b/backend/internal/modules/invitation/admin_filters_test.go index 47c1e53..384041a 100644 --- a/backend/internal/modules/invitation/admin_filters_test.go +++ b/backend/internal/modules/invitation/admin_filters_test.go @@ -74,6 +74,110 @@ func TestListAdminInvitationsFiltersPhoneAndInviteCode(t *testing.T) { } } +func TestListAdminInvitationsIncludesInviterAggregateStats(t *testing.T) { + db := setupTestDB(t) + repo := NewRepository(db) + ctx := context.Background() + now := time.Now() + + inviter := model.User{ + Phone: "13800000001", + Nickname: "邀请人A", + Status: "active", + RealnameStatus: "verified", + } + if err := db.Create(&inviter).Error; err != nil { + t.Fatalf("创建邀请人失败: %v", err) + } + + specs := []struct { + phone string + status string + siteID uint64 + verified bool + }{ + {phone: "13900000001", status: StatusActive, siteID: 1, verified: true}, + {phone: "13900000002", status: StatusActive, siteID: 1, verified: true}, + {phone: "13900000003", status: StatusPending, siteID: 2, verified: false}, + {phone: "13900000004", status: StatusPending, siteID: 0, verified: false}, + {phone: "13900000005", status: StatusInvalid, siteID: 1, verified: false}, + } + for _, spec := range specs { + invitee := model.User{ + Phone: spec.phone, + Nickname: "被邀请人", + Status: "active", + RealnameStatus: "verified", + } + if err := db.Create(&invitee).Error; err != nil { + t.Fatalf("创建被邀请人失败: %v", err) + } + record := model.UserInvitation{ + InviterID: inviter.ID, + InviteeID: invitee.ID, + DistributionSiteID: spec.siteID, + InviteCode: "aggcode", + Status: spec.status, + CreatedAt: now, + } + if spec.verified { + record.VerifiedAt = &now + } + if err := db.Create(&record).Error; err != nil { + t.Fatalf("创建邀请关系失败: %v", err) + } + } + + assertStats := func(t *testing.T, items []map[string]any, wantTotal, wantActive, wantPending, wantInvalid int64) { + t.Helper() + if len(items) == 0 { + t.Fatal("查询结果为空") + } + for _, item := range items { + if got := item["inviter_total_invited"]; got != wantTotal { + t.Fatalf("累计邀请人数 = %v, want %d", got, wantTotal) + } + if got := item["inviter_active_invited"]; got != wantActive { + t.Fatalf("已实名人数 = %v, want %d", got, wantActive) + } + if got := item["inviter_pending_invited"]; got != wantPending { + t.Fatalf("待实名人数 = %v, want %d", got, wantPending) + } + if got := item["inviter_invalid_invited"]; got != wantInvalid { + t.Fatalf("无效邀请人数 = %v, want %d", got, wantInvalid) + } + } + } + + items, total, err := repo.ListAdminInvitations(ctx, AdminInvitationQuery{ + Page: 1, + PageSize: 20, + InviterID: inviter.ID, + }) + if err != nil { + t.Fatalf("查询邀请关系失败: %v", err) + } + if total != 5 { + t.Fatalf("查询结果数量 = %d, want 5", total) + } + assertStats(t, items, 5, 2, 2, 1) + + siteID := uint64(1) + items, total, err = repo.ListAdminInvitations(ctx, AdminInvitationQuery{ + Page: 1, + PageSize: 20, + InviterID: inviter.ID, + DistributionSiteID: &siteID, + }) + if err != nil { + t.Fatalf("按站点查询邀请关系失败: %v", err) + } + if total != 3 { + t.Fatalf("站点查询结果数量 = %d, want 3", total) + } + assertStats(t, items, 3, 2, 0, 1) +} + func TestListAdminRebateLedgersFiltersPartialPhoneAndOrderNo(t *testing.T) { db := setupTestDB(t) repo := NewRepository(db) diff --git a/backend/internal/modules/invitation/repository.go b/backend/internal/modules/invitation/repository.go index 6b01d6e..a15d868 100644 --- a/backend/internal/modules/invitation/repository.go +++ b/backend/internal/modules/invitation/repository.go @@ -922,21 +922,69 @@ func (r *Repository) ListAdminInvitations(ctx context.Context, query AdminInvita return nil, 0, err } + // 汇总本页邀请人名下的累计邀请人数(一次 IN 查询,避免逐行统计)。 + inviterIDs := make([]uint64, 0, len(rows)) + seenInviter := make(map[uint64]struct{}, len(rows)) + for _, row := range rows { + if row.InviterID == 0 { + continue + } + if _, ok := seenInviter[row.InviterID]; ok { + continue + } + seenInviter[row.InviterID] = struct{}{} + inviterIDs = append(inviterIDs, row.InviterID) + } + + type inviterStatRow struct { + InviterID uint64 `gorm:"column:inviter_id"` + TotalInvited int64 `gorm:"column:total_invited"` + ActiveInvited int64 `gorm:"column:active_invited"` + PendingInvited int64 `gorm:"column:pending_invited"` + InvalidInvited int64 `gorm:"column:invalid_invited"` + } + inviterStats := make(map[uint64]inviterStatRow, len(inviterIDs)) + if len(inviterIDs) > 0 { + var statRows []inviterStatRow + statDB := r.db.WithContext(ctx).Model(&model.UserInvitation{}). + Select("inviter_id, COUNT(*) AS total_invited, "+ + "SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active_invited, "+ + "SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending_invited, "+ + "SUM(CASE WHEN status = 'invalid' THEN 1 ELSE 0 END) AS invalid_invited"). + Where("inviter_id IN ?", inviterIDs). + Group("inviter_id") + // 分站后台只能看到本站范围内的累计数据,避免跨站泄露。 + if query.DistributionSiteID != nil { + statDB = statDB.Where("distribution_site_id = ?", *query.DistributionSiteID) + } + if err := statDB.Scan(&statRows).Error; err != nil { + return nil, 0, err + } + for _, stat := range statRows { + inviterStats[stat.InviterID] = stat + } + } + list := make([]map[string]any, 0, len(rows)) for _, row := range rows { + stat := inviterStats[row.InviterID] list = append(list, map[string]any{ - "id": row.ID, - "inviter_id": row.InviterID, - "inviter_phone": row.InviterPhone, - "inviter_nickname": row.InviterNickname, - "invitee_id": row.InviteeID, - "invitee_phone": row.InviteePhone, - "invitee_nickname": row.InviteeNickname, - "distribution_site_id": row.DistributionSiteID, - "invite_code": row.InviteCode, - "status": row.Status, - "verified_at": row.VerifiedAt, - "created_at": row.CreatedAt, + "id": row.ID, + "inviter_id": row.InviterID, + "inviter_phone": row.InviterPhone, + "inviter_nickname": row.InviterNickname, + "inviter_total_invited": stat.TotalInvited, + "inviter_active_invited": stat.ActiveInvited, + "inviter_pending_invited": stat.PendingInvited, + "inviter_invalid_invited": stat.InvalidInvited, + "invitee_id": row.InviteeID, + "invitee_phone": row.InviteePhone, + "invitee_nickname": row.InviteeNickname, + "distribution_site_id": row.DistributionSiteID, + "invite_code": row.InviteCode, + "status": row.Status, + "verified_at": row.VerifiedAt, + "created_at": row.CreatedAt, }) } return list, total, nil diff --git a/frontend/src/features/admin/api/adminInvitations.ts b/frontend/src/features/admin/api/adminInvitations.ts index 84f0e2f..72966e7 100644 --- a/frontend/src/features/admin/api/adminInvitations.ts +++ b/frontend/src/features/admin/api/adminInvitations.ts @@ -8,6 +8,14 @@ export interface AdminInvitationRow { inviter_id: number inviter_phone: string inviter_nickname: string + /** 邀请人名下累计邀请人数(受当前站点范围限制) */ + inviter_total_invited: number + /** 邀请人名下已实名生效人数 */ + inviter_active_invited: number + /** 邀请人名下待实名人数 */ + inviter_pending_invited: number + /** 邀请人名下无效邀请人数 */ + inviter_invalid_invited: number invitee_id: number invitee_phone: string invitee_nickname: string diff --git a/frontend/src/features/admin/views/AdminInvitationsView.vue b/frontend/src/features/admin/views/AdminInvitationsView.vue index f882d33..ee58ee8 100644 --- a/frontend/src/features/admin/views/AdminInvitationsView.vue +++ b/frontend/src/features/admin/views/AdminInvitationsView.vue @@ -196,7 +196,7 @@ function cycleLabel(cycle: string) { @@ -320,6 +320,9 @@ function cycleLabel(cycle: string) { 查询 刷新 +
+ 「邀请人累计」统计该邀请人名下全部邀请人数;分站后台仅统计本站范围。 +
{{ row.inviter_phone }} + + + + + + + + + + +