feat(invitation): 新增返利统计汇总与时间筛选

This commit is contained in:
yml2213
2026-10-29 12:36:57 +08:00
parent 2029c1f4fb
commit 163ffb9eff
8 changed files with 913 additions and 14 deletions
@@ -6,6 +6,8 @@ import (
"time"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
func TestListAdminInvitationsFiltersPhoneAndInviteCode(t *testing.T) {
@@ -74,6 +76,244 @@ func TestListAdminInvitationsFiltersPhoneAndInviteCode(t *testing.T) {
}
}
// seedRebateSummaryFixture 构造覆盖订单/提号、买家/卖家、三个周期阶段与跨月时间的流水。
func seedRebateSummaryFixture(t *testing.T, db *gorm.DB) []model.User {
t.Helper()
users := []model.User{
{Phone: "13800001001", Nickname: "邀请人A", Status: "active", RealnameStatus: "verified"},
{Phone: "13800001002", Nickname: "邀请人B", Status: "active", RealnameStatus: "verified"},
{Phone: "13900001001", Nickname: "被邀请人C", Status: "active", RealnameStatus: "verified"},
{Phone: "13900001002", Nickname: "被邀请人D", Status: "active", RealnameStatus: "verified"},
}
for i := range users {
if err := db.Create(&users[i]).Error; err != nil {
t.Fatalf("创建测试用户失败: %v", err)
}
}
ledgers := []model.InvitationRebateLedger{
{
OrderID: 3001,
OrderNo: "ORDER-2026-01-10-A",
Source: "rental_order",
InviterID: users[0].ID,
InviteeID: users[2].ID,
TradeRole: "buyer",
RateApplied: 0.6,
CycleStage: CycleFirstMonth,
RebateAmountCent: 100,
Status: "settled",
SettledAt: time.Date(2026, 1, 10, 10, 0, 0, 0, time.Local),
},
{
OrderID: 3002,
OrderNo: "ORDER-2026-01-10-B",
Source: "rental_order",
InviterID: users[0].ID,
InviteeID: users[3].ID,
TradeRole: "seller",
RateApplied: 0.3,
CycleStage: CycleSecondMonth,
RebateAmountCent: 200,
Status: "settled",
SettledAt: time.Date(2026, 1, 10, 15, 0, 0, 0, time.Local),
},
{
OrderID: 3003,
OrderNo: "PICKUP-2026-02-05",
Source: "pickup",
InviterID: users[1].ID,
InviteeID: users[2].ID,
TradeRole: "seller",
RateApplied: 0.15,
CycleStage: CycleSubsequent,
RebateAmountCent: 300,
Status: "settled",
SettledAt: time.Date(2026, 2, 5, 9, 30, 0, 0, time.Local),
},
{
OrderID: 3004,
OrderNo: "ORDER-2026-03-01",
Source: "rental_order",
InviterID: users[0].ID,
InviteeID: users[3].ID,
TradeRole: "buyer",
RateApplied: 0.6,
CycleStage: CycleFirstMonth,
RebateAmountCent: 400,
Status: "settled",
SettledAt: time.Date(2026, 3, 1, 18, 0, 0, 0, time.Local),
},
}
for i := range ledgers {
if err := db.Create(&ledgers[i]).Error; err != nil {
t.Fatalf("创建返利流水失败: %v", err)
}
}
return users
}
func TestListAdminRebateLedgersFiltersSourceAndDateRange(t *testing.T) {
db := setupTestDB(t)
repo := NewRepository(db)
ctx := context.Background()
seedRebateSummaryFixture(t, db)
cases := []struct {
name string
query AdminRebateLedgerQuery
want int64
}{
{name: "来源筛选取号", query: AdminRebateLedgerQuery{Source: "pickup"}, want: 1},
{name: "来源筛选订单", query: AdminRebateLedgerQuery{Source: "rental_order"}, want: 3},
{name: "交易端筛选买家", query: AdminRebateLedgerQuery{TradeRole: "buyer"}, want: 2},
{
name: "时间范围筛选单月",
query: AdminRebateLedgerQuery{StartDate: "2026-01-01", EndDate: "2026-01-31"},
want: 2,
},
{
name: "时间范围筛选起始日",
query: AdminRebateLedgerQuery{StartDate: "2026-02-01"},
want: 2,
},
{
name: "时间范围筛选结束日",
query: AdminRebateLedgerQuery{EndDate: "2026-01-31"},
want: 2,
},
{
name: "来源与时间范围组合",
query: AdminRebateLedgerQuery{
Source: "rental_order",
StartDate: "2026-02-01",
EndDate: "2026-02-28",
},
want: 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tc.query.Page = 1
tc.query.PageSize = 20
_, total, err := repo.ListAdminRebateLedgers(ctx, tc.query)
if err != nil {
t.Fatalf("查询返利流水失败: %v", err)
}
if total != tc.want {
t.Fatalf("查询结果数量 = %d, want %d", total, tc.want)
}
})
}
}
func TestAdminRebateSummaryAggregatesAndBreakdowns(t *testing.T) {
db := setupTestDB(t)
repo := NewRepository(db)
ctx := context.Background()
users := seedRebateSummaryFixture(t, db)
summary, err := repo.AdminRebateSummary(ctx, AdminRebateLedgerQuery{})
if err != nil {
t.Fatalf("查询返利统计失败: %v", err)
}
if summary.TotalRebateCent != 1000 {
t.Fatalf("返利总额 = %d, want 1000", summary.TotalRebateCent)
}
if summary.LedgerCount != 4 {
t.Fatalf("流水笔数 = %d, want 4", summary.LedgerCount)
}
if summary.InviterCount != 2 {
t.Fatalf("邀请人数 = %d, want 2", summary.InviterCount)
}
if summary.InviteeCount != 2 {
t.Fatalf("被邀请人数 = %d, want 2", summary.InviteeCount)
}
if summary.AvgRebateCent != 250 {
t.Fatalf("平均返利 = %d, want 250", summary.AvgRebateCent)
}
if summary.OrderRebateCent != 700 || summary.OrderCount != 3 {
t.Fatalf("订单返利 = %d/%d 笔, want 700/3", summary.OrderRebateCent, summary.OrderCount)
}
if summary.PickupRebateCent != 300 || summary.PickupCount != 1 {
t.Fatalf("提号返利 = %d/%d 笔, want 300/1", summary.PickupRebateCent, summary.PickupCount)
}
if summary.BuyerRebateCent != 500 || summary.BuyerCount != 2 {
t.Fatalf("买家端返利 = %d/%d 笔, want 500/2", summary.BuyerRebateCent, summary.BuyerCount)
}
if summary.SellerRebateCent != 500 || summary.SellerCount != 2 {
t.Fatalf("卖家端返利 = %d/%d 笔, want 500/2", summary.SellerRebateCent, summary.SellerCount)
}
if summary.FirstMonthCent != 500 || summary.SecondMonthCent != 200 || summary.SubsequentCent != 300 {
t.Fatalf(
"周期返利 = %d/%d/%d, want 500/200/300",
summary.FirstMonthCent,
summary.SecondMonthCent,
summary.SubsequentCent,
)
}
if len(summary.Daily) != 3 {
t.Fatalf("每日统计行数 = %d, want 3", len(summary.Daily))
}
daily := make(map[string]AdminRebateDailyStat, len(summary.Daily))
for _, row := range summary.Daily {
daily[row.Date] = row
}
january, ok := daily["2026-01-10"]
if !ok {
t.Fatalf("缺少 2026-01-10 的每日统计: %+v", summary.Daily)
}
if january.LedgerCount != 2 || january.RebateAmountCent != 300 ||
january.OrderAmountCent != 300 || january.PickupAmountCent != 0 {
t.Fatalf("2026-01-10 统计错误: %+v", january)
}
if got := daily["2026-02-05"]; got.PickupAmountCent != 300 || got.OrderAmountCent != 0 {
t.Fatalf("2026-02-05 统计错误: %+v", got)
}
if got := daily["2026-03-01"]; got.RebateAmountCent != 400 {
t.Fatalf("2026-03-01 统计错误: %+v", got)
}
if len(summary.TopInviters) != 2 {
t.Fatalf("Top 邀请人数 = %d, want 2", len(summary.TopInviters))
}
top := summary.TopInviters[0]
if top.InviterID != users[0].ID || top.RebateAmountCent != 700 || top.LedgerCount != 3 {
t.Fatalf("Top 邀请人统计错误: %+v", top)
}
if top.InviterPhone != users[0].Phone || top.InviterNickname != users[0].Nickname {
t.Fatalf("Top 邀请人信息错误: %+v", top)
}
filtered, err := repo.AdminRebateSummary(ctx, AdminRebateLedgerQuery{
StartDate: "2026-01-01",
EndDate: "2026-01-31",
})
if err != nil {
t.Fatalf("按时间查询返利统计失败: %v", err)
}
if filtered.TotalRebateCent != 300 || filtered.LedgerCount != 2 || len(filtered.Daily) != 1 {
t.Fatalf(
"时间筛选统计错误: total=%d count=%d daily=%d",
filtered.TotalRebateCent,
filtered.LedgerCount,
len(filtered.Daily),
)
}
pickupOnly, err := repo.AdminRebateSummary(ctx, AdminRebateLedgerQuery{Source: "pickup"})
if err != nil {
t.Fatalf("按来源查询返利统计失败: %v", err)
}
if pickupOnly.TotalRebateCent != 300 || pickupOnly.LedgerCount != 1 ||
pickupOnly.OrderCount != 0 || pickupOnly.PickupCount != 1 {
t.Fatalf("来源筛选统计错误: %+v", pickupOnly)
}
}
func TestListAdminInvitationsIncludesInviterAggregateStats(t *testing.T) {
db := setupTestDB(t)
repo := NewRepository(db)
@@ -98,8 +98,49 @@ type AdminRebateLedgerQuery struct {
InviterPhone string `form:"inviter_phone"`
InviteePhone string `form:"invitee_phone"`
TradeRole string `form:"trade_role"`
Source string `form:"source"`
StartDate string `form:"start_date"`
EndDate string `form:"end_date"`
DistributionSiteID *uint64 `form:"distribution_site_id"`
// InviterID / InviteeID 供用户管理「明细」按用户精确筛选。
InviterID uint64 `form:"inviter_id"`
InviteeID uint64 `form:"invitee_id"`
}
// AdminRebateSummaryDTO 返利统计页汇总数据,所有金额单位为分。
type AdminRebateSummaryDTO struct {
TotalRebateCent int64 `json:"total_rebate_cent"`
LedgerCount int64 `json:"ledger_count"`
InviterCount int64 `json:"inviter_count"`
InviteeCount int64 `json:"invitee_count"`
AvgRebateCent int64 `json:"avg_rebate_cent"`
OrderRebateCent int64 `json:"order_rebate_cent"`
OrderCount int64 `json:"order_count"`
PickupRebateCent int64 `json:"pickup_rebate_cent"`
PickupCount int64 `json:"pickup_count"`
BuyerRebateCent int64 `json:"buyer_rebate_cent"`
BuyerCount int64 `json:"buyer_count"`
SellerRebateCent int64 `json:"seller_rebate_cent"`
SellerCount int64 `json:"seller_count"`
FirstMonthCent int64 `json:"first_month_cent"`
SecondMonthCent int64 `json:"second_month_cent"`
SubsequentCent int64 `json:"subsequent_cent"`
Daily []AdminRebateDailyStat `json:"daily"`
TopInviters []AdminRebateInviterStat `json:"top_inviters"`
}
type AdminRebateDailyStat struct {
Date string `json:"date"`
LedgerCount int64 `json:"ledger_count"`
RebateAmountCent int64 `json:"rebate_amount_cent"`
OrderAmountCent int64 `json:"order_amount_cent"`
PickupAmountCent int64 `json:"pickup_amount_cent"`
}
type AdminRebateInviterStat struct {
InviterID uint64 `json:"inviter_id"`
InviterPhone string `json:"inviter_phone"`
InviterNickname string `json:"inviter_nickname"`
LedgerCount int64 `json:"ledger_count"`
RebateAmountCent int64 `json:"rebate_amount_cent"`
}
@@ -170,6 +170,21 @@ func (h *Handler) AdminRebates(c *gin.Context) {
})
}
// AdminRebateSummary 管理员查看返利统计汇总
func (h *Handler) AdminRebateSummary(c *gin.Context) {
var query AdminRebateLedgerQuery
if err := c.ShouldBindQuery(&query); err != nil {
response.BadRequest(c, "查询参数不正确")
return
}
summary, err := h.service.AdminRebateSummary(c.Request.Context(), query)
if err != nil {
writeInvitationError(c, err)
return
}
response.OK(c, summary)
}
func currentUserID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextUserID)
if !ok {
+174 -11
View File
@@ -998,17 +998,8 @@ func (r *Repository) ListAdminInvitations(ctx context.Context, query AdminInvita
return list, total, nil
}
// ListAdminRebateLedgers 管理后台查询返利流水
func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminRebateLedgerQuery) ([]map[string]any, int64, error) {
if r == nil || r.db == nil {
return nil, 0, ErrDependencyUnavailable
}
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 || query.PageSize > 100 {
query.PageSize = 20
}
// adminRebateLedgerBase 构造后台返利流水/统计数据共用的筛选范围。
func (r *Repository) adminRebateLedgerBase(ctx context.Context, query AdminRebateLedgerQuery) *gorm.DB {
db := r.db.WithContext(ctx).Table("invitation_rebate_ledgers irl").
Joins("LEFT JOIN users inviter ON inviter.id = irl.inviter_id").
Joins("LEFT JOIN users invitee ON invitee.id = irl.invitee_id")
@@ -1025,6 +1016,15 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba
if t := strings.TrimSpace(query.TradeRole); t != "" {
db = db.Where("irl.trade_role = ?", t)
}
if s := strings.TrimSpace(query.Source); s != "" {
db = db.Where("irl.source = ?", s)
}
if start, ok := parseAdminRebateDate(query.StartDate); ok {
db = db.Where("irl.settled_at >= ?", start)
}
if end, ok := parseAdminRebateDate(query.EndDate); ok {
db = db.Where("irl.settled_at < ?", end.AddDate(0, 0, 1))
}
if query.DistributionSiteID != nil {
db = db.Where("irl.distribution_site_id = ?", *query.DistributionSiteID)
}
@@ -1034,6 +1034,34 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba
if query.InviteeID > 0 {
db = db.Where("irl.invitee_id = ?", query.InviteeID)
}
return db
}
func parseAdminRebateDate(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
for _, layout := range []string{"2006-01-02", "2006-01-02 15:04:05", time.RFC3339} {
if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
// ListAdminRebateLedgers 管理后台查询返利流水
func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminRebateLedgerQuery) ([]map[string]any, int64, error) {
if r == nil || r.db == nil {
return nil, 0, ErrDependencyUnavailable
}
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 || query.PageSize > 100 {
query.PageSize = 20
}
db := r.adminRebateLedgerBase(ctx, query)
var total int64
if err := db.Count(&total).Error; err != nil {
@@ -1095,6 +1123,141 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba
return list, total, nil
}
// AdminRebateSummary 管理后台返利统计分析,沿用流水页的筛选条件。
func (r *Repository) AdminRebateSummary(ctx context.Context, query AdminRebateLedgerQuery) (*AdminRebateSummaryDTO, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
type aggregateRow struct {
TotalRebateCent int64 `gorm:"column:total_rebate_cent"`
LedgerCount int64 `gorm:"column:ledger_count"`
InviterCount int64 `gorm:"column:inviter_count"`
InviteeCount int64 `gorm:"column:invitee_count"`
OrderRebateCent int64 `gorm:"column:order_rebate_cent"`
OrderCount int64 `gorm:"column:order_count"`
PickupRebateCent int64 `gorm:"column:pickup_rebate_cent"`
PickupCount int64 `gorm:"column:pickup_count"`
BuyerRebateCent int64 `gorm:"column:buyer_rebate_cent"`
BuyerCount int64 `gorm:"column:buyer_count"`
SellerRebateCent int64 `gorm:"column:seller_rebate_cent"`
SellerCount int64 `gorm:"column:seller_count"`
FirstMonthCent int64 `gorm:"column:first_month_cent"`
SecondMonthCent int64 `gorm:"column:second_month_cent"`
SubsequentCent int64 `gorm:"column:subsequent_cent"`
}
var aggregate aggregateRow
err := r.adminRebateLedgerBase(ctx, query).Select(`
COALESCE(SUM(irl.rebate_amount_cent), 0) AS total_rebate_cent,
COUNT(*) AS ledger_count,
COUNT(DISTINCT irl.inviter_id) AS inviter_count,
COUNT(DISTINCT irl.invitee_id) AS invitee_count,
COALESCE(SUM(CASE WHEN irl.source = 'rental_order' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS order_rebate_cent,
COALESCE(SUM(CASE WHEN irl.source = 'rental_order' THEN 1 ELSE 0 END), 0) AS order_count,
COALESCE(SUM(CASE WHEN irl.source = 'pickup' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS pickup_rebate_cent,
COALESCE(SUM(CASE WHEN irl.source = 'pickup' THEN 1 ELSE 0 END), 0) AS pickup_count,
COALESCE(SUM(CASE WHEN irl.trade_role = 'buyer' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS buyer_rebate_cent,
COALESCE(SUM(CASE WHEN irl.trade_role = 'buyer' THEN 1 ELSE 0 END), 0) AS buyer_count,
COALESCE(SUM(CASE WHEN irl.trade_role = 'seller' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS seller_rebate_cent,
COALESCE(SUM(CASE WHEN irl.trade_role = 'seller' THEN 1 ELSE 0 END), 0) AS seller_count,
COALESCE(SUM(CASE WHEN irl.cycle_stage = ? THEN irl.rebate_amount_cent ELSE 0 END), 0) AS first_month_cent,
COALESCE(SUM(CASE WHEN irl.cycle_stage = ? THEN irl.rebate_amount_cent ELSE 0 END), 0) AS second_month_cent,
COALESCE(SUM(CASE WHEN irl.cycle_stage = ? THEN irl.rebate_amount_cent ELSE 0 END), 0) AS subsequent_cent
`, CycleFirstMonth, CycleSecondMonth, CycleSubsequent).Scan(&aggregate).Error
if err != nil {
return nil, err
}
result := &AdminRebateSummaryDTO{
TotalRebateCent: aggregate.TotalRebateCent,
LedgerCount: aggregate.LedgerCount,
InviterCount: aggregate.InviterCount,
InviteeCount: aggregate.InviteeCount,
OrderRebateCent: aggregate.OrderRebateCent,
OrderCount: aggregate.OrderCount,
PickupRebateCent: aggregate.PickupRebateCent,
PickupCount: aggregate.PickupCount,
BuyerRebateCent: aggregate.BuyerRebateCent,
BuyerCount: aggregate.BuyerCount,
SellerRebateCent: aggregate.SellerRebateCent,
SellerCount: aggregate.SellerCount,
FirstMonthCent: aggregate.FirstMonthCent,
SecondMonthCent: aggregate.SecondMonthCent,
SubsequentCent: aggregate.SubsequentCent,
Daily: make([]AdminRebateDailyStat, 0),
TopInviters: make([]AdminRebateInviterStat, 0),
}
if result.LedgerCount > 0 {
result.AvgRebateCent = result.TotalRebateCent / result.LedgerCount
}
type dailyRow struct {
Date string `gorm:"column:date"`
LedgerCount int64 `gorm:"column:ledger_count"`
RebateAmountCent int64 `gorm:"column:rebate_amount_cent"`
OrderAmountCent int64 `gorm:"column:order_amount_cent"`
PickupAmountCent int64 `gorm:"column:pickup_amount_cent"`
}
var daily []dailyRow
err = r.adminRebateLedgerBase(ctx, query).
Select(`
DATE_FORMAT(irl.settled_at, '%Y-%m-%d') AS date,
COUNT(*) AS ledger_count,
COALESCE(SUM(irl.rebate_amount_cent), 0) AS rebate_amount_cent,
COALESCE(SUM(CASE WHEN irl.source = 'rental_order' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS order_amount_cent,
COALESCE(SUM(CASE WHEN irl.source = 'pickup' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS pickup_amount_cent
`).
Group("DATE_FORMAT(irl.settled_at, '%Y-%m-%d')").
Order("DATE_FORMAT(irl.settled_at, '%Y-%m-%d') DESC").
Scan(&daily).Error
if err != nil {
return nil, err
}
for _, row := range daily {
result.Daily = append(result.Daily, AdminRebateDailyStat{
Date: row.Date,
LedgerCount: row.LedgerCount,
RebateAmountCent: row.RebateAmountCent,
OrderAmountCent: row.OrderAmountCent,
PickupAmountCent: row.PickupAmountCent,
})
}
type topInviterRow struct {
InviterID uint64 `gorm:"column:inviter_id"`
InviterPhone string `gorm:"column:inviter_phone"`
InviterNickname string `gorm:"column:inviter_nickname"`
LedgerCount int64 `gorm:"column:ledger_count"`
RebateAmountCent int64 `gorm:"column:rebate_amount_cent"`
}
var top []topInviterRow
err = r.adminRebateLedgerBase(ctx, query).
Select(`
irl.inviter_id,
COALESCE(inviter.phone, '') AS inviter_phone,
COALESCE(inviter.nickname, '') AS inviter_nickname,
COUNT(*) AS ledger_count,
COALESCE(SUM(irl.rebate_amount_cent), 0) AS rebate_amount_cent
`).
Group("irl.inviter_id, inviter.phone, inviter.nickname").
Order("rebate_amount_cent DESC").
Limit(10).
Scan(&top).Error
if err != nil {
return nil, err
}
for _, row := range top {
result.TopInviters = append(result.TopInviters, AdminRebateInviterStat{
InviterID: row.InviterID,
InviterPhone: row.InviterPhone,
InviterNickname: row.InviterNickname,
LedgerCount: row.LedgerCount,
RebateAmountCent: row.RebateAmountCent,
})
}
return result, nil
}
// ProcessOrderInvitationRebateTx 便捷事务入口,供订单结账完成终态时调用
func ProcessOrderInvitationRebateTx(tx *gorm.DB, orderID uint64) error {
return ProcessOrderInvitationRebateWithOptionsTx(tx, orderID, OrderRebateOptions{})
@@ -107,3 +107,10 @@ func (s *Service) ListAdminRebateLedgers(ctx context.Context, query AdminRebateL
}
return s.repo.ListAdminRebateLedgers(ctx, query)
}
func (s *Service) AdminRebateSummary(ctx context.Context, query AdminRebateLedgerQuery) (*AdminRebateSummaryDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.AdminRebateSummary(ctx, query)
}