feat: 邀请关系列表增加邀请人累计统计
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -196,7 +196,7 @@ function cycleLabel(cycle: string) {
|
||||
<div class="page-header">
|
||||
<h2>邀请返利与分成管理</h2>
|
||||
<p class="page-desc">
|
||||
配置主站及各分销站点的返利阶梯比例(首月、次月、往后永久),监控全网邀请关系与返利流水。
|
||||
配置主站及各分销站点的返利阶梯比例(首月、次月、往后永久),监控全网邀请关系与返利流水,快速查看每位邀请人的累计邀请、已实名与待实名人数。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -320,6 +320,9 @@ function cycleLabel(cycle: string) {
|
||||
<el-button type="primary" :icon="Search" @click="searchInvitations">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="searchInvitations">刷新</el-button>
|
||||
</div>
|
||||
<div class="table-hint">
|
||||
「邀请人累计」统计该邀请人名下全部邀请人数;分站后台仅统计本站范围。
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loadingInvitations"
|
||||
@@ -334,6 +337,23 @@ function cycleLabel(cycle: string) {
|
||||
<code style="font-size: 12px; color: #64748b">{{ row.inviter_phone }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="邀请人累计" align="center">
|
||||
<el-table-column label="总邀请" width="86" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="invite-stat-total">{{ row.inviter_total_invited ?? 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已实名" width="86" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="invite-stat-active">{{ row.inviter_active_invited ?? 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="待实名" width="86" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="invite-stat-pending">{{ row.inviter_pending_invited ?? 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table-column>
|
||||
<el-table-column label="被邀请人" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.invitee_nickname || '-' }}</div>
|
||||
@@ -575,6 +595,27 @@ function cycleLabel(cycle: string) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.table-hint {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.invite-stat-total {
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.invite-stat-active {
|
||||
font-weight: 700;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.invite-stat-pending {
|
||||
font-weight: 700;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.pagination-box {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user