package invitation import ( "context" "fmt" "testing" "time" "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" "gorm.io/gorm" ) func setupTestDB(t *testing.T) *gorm.DB { db := database.NewTestDB() if err := database.AutoMigrateIfMissing(db, &model.User{}, &model.UserInvitation{}, &model.InvitationRebateConfig{}, &model.InvitationRebateLedger{}, &model.RentalOrder{}, &model.DistributionSite{}, &model.DistributionOrderParty{}, &model.WalletAccount{}, &model.WalletLedger{}, ); err != nil { t.Fatalf("数据库迁移失败: %v", err) } return db } func TestCalculateRebateRate(t *testing.T) { now := time.Now() cfg := &model.InvitationRebateConfig{ FirstMonthRate: 0.60, SecondMonthRate: 0.30, SubsequentRate: 0.15, Enabled: true, } // 10 天(首月) rate, cycle, days := CalculateRebateRate(now.AddDate(0, 0, -10), now, cfg) if cycle != CycleFirstMonth || rate != 0.60 || days != 10 { t.Fatalf("首月比例计算错误: cycle=%s, rate=%v, days=%d", cycle, rate, days) } // 30 天(首月边界) rate, cycle, days = CalculateRebateRate(now.AddDate(0, 0, -30), now, cfg) if cycle != CycleFirstMonth || rate != 0.60 || days != 30 { t.Fatalf("首月30天边界计算错误: cycle=%s, rate=%v, days=%d", cycle, rate, days) } // 45 天(次月) rate, cycle, days = CalculateRebateRate(now.AddDate(0, 0, -45), now, cfg) if cycle != CycleSecondMonth || rate != 0.30 || days != 45 { t.Fatalf("次月比例计算错误: cycle=%s, rate=%v, days=%d", cycle, rate, days) } // 60 天(次月边界) rate, cycle, days = CalculateRebateRate(now.AddDate(0, 0, -60), now, cfg) if cycle != CycleSecondMonth || rate != 0.30 || days != 60 { t.Fatalf("次月60天边界计算错误: cycle=%s, rate=%v, days=%d", cycle, rate, days) } // 90 天(往后长期) rate, cycle, days = CalculateRebateRate(now.AddDate(0, 0, -90), now, cfg) if cycle != CycleSubsequent || rate != 0.15 || days != 90 { t.Fatalf("长期比例计算错误: cycle=%s, rate=%v, days=%d", cycle, rate, days) } } func TestInvitationFlowAndOrderRebate(t *testing.T) { db := setupTestDB(t) repo := NewRepository(db) ctx := context.Background() // 1. 创建邀请人 Inviter (已实名) inviterCode := "inv0001" inviter := model.User{ Phone: "13800000001", Nickname: "邀请人老张", RealnameStatus: "verified", Status: "active", InviteCode: &inviterCode, } if err := db.Create(&inviter).Error; err != nil { t.Fatalf("创建邀请人失败: %v", err) } // 2. 创建被邀请人 Invitee (未实名) invitee := model.User{ Phone: "13800000002", Nickname: "新人小李", RealnameStatus: "unverified", Status: "active", } if err := db.Create(&invitee).Error; err != nil { t.Fatalf("创建被邀请人失败: %v", err) } // 3. 绑定邀请码 if err := repo.BindInvitation(ctx, invitee.ID, inviterCode, 0); err != nil { t.Fatalf("绑定邀请码失败: %v", err) } // 校验处于 pending 状态(未实名尚未正式生效) var invRecord model.UserInvitation if err := db.Where("invitee_id = ?", invitee.ID).First(&invRecord).Error; err != nil { t.Fatalf("查询邀请记录失败: %v", err) } if invRecord.Status != StatusPending || invRecord.VerifiedAt != nil { t.Fatalf("邀请状态应为 pending,实际: %s", invRecord.Status) } // 4. 被邀请人完成实名认证 if err := db.Model(&model.User{}).Where("id = ?", invitee.ID).Update("realname_status", "verified").Error; err != nil { t.Fatalf("更新实名失败: %v", err) } if err := repo.OnUserRealnameVerified(ctx, invitee.ID); err != nil { t.Fatalf("触发实名生效钩子失败: %v", err) } if err := db.Where("invitee_id = ?", invitee.ID).First(&invRecord).Error; err != nil { t.Fatalf("查询生效后邀请记录失败: %v", err) } if invRecord.Status != StatusActive || invRecord.VerifiedAt == nil { t.Fatalf("邀请状态应为 active 且包含 verified_at,实际: %s", invRecord.Status) } // 检查被邀请人自身是否自动生成了邀请码 var updatedInvitee model.User if err := db.First(&updatedInvitee, invitee.ID).Error; err != nil { t.Fatalf("查询被邀请人失败: %v", err) } if updatedInvitee.InviteCode == nil || *updatedInvitee.InviteCode == "" { t.Fatalf("实名通过后自身应自动生成邀请码") } // 5. 模拟订单完成终态结算 orderNo := fmt.Sprintf("ORD-%d", time.Now().UnixNano()) order := model.RentalOrder{ OrderNo: orderNo, RenterID: invitee.ID, // 被邀请人作为买家 OwnerID: 99999, // 独立卖家(无邀请人) Status: "completed", SettlementStatus: "settled", RentAmountCent: 20000, // 200元 OwnerRentAmountCent: 10000, // 100元号主成本 // 平台毛利: 10000 (100元) // 单边基准: 5000 (50元) // 首月 60% 返利: 5000 * 0.6 = 3000 (30元) } if err := db.Create(&order).Error; err != nil { t.Fatalf("创建测试订单失败: %v", err) } if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil { t.Fatalf("订单返利处理失败: %v", err) } // 验证流水表 var ledgers []model.InvitationRebateLedger if err := db.Where("order_id = ?", order.ID).Find(&ledgers).Error; err != nil { t.Fatalf("查询返利记录失败: %v", err) } if len(ledgers) != 1 { t.Fatalf("应生成 1 条买家端返利流水,实际: %d", len(ledgers)) } l := ledgers[0] if l.InviterID != inviter.ID || l.TradeRole != "buyer" || l.RebateAmountCent != 3000 { t.Fatalf("返利金额错误: expected=3000, got=%d, role=%s", l.RebateAmountCent, l.TradeRole) } // 验证邀请人钱包余额 var walletAcct model.WalletAccount if err := db.Where("user_id = ? AND distribution_site_id = 0", inviter.ID).First(&walletAcct).Error; err != nil { t.Fatalf("查询邀请人钱包失败: %v", err) } if walletAcct.AvailableBalanceCent != 3000 { t.Fatalf("邀请人可用余额错误: expected=3000, got=%d", walletAcct.AvailableBalanceCent) } // 幂等性测试:再次触发不应重复生成 if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil { t.Fatalf("再次调用不应报错: %v", err) } var count int64 db.Model(&model.InvitationRebateLedger{}).Where("order_id = ?", order.ID).Count(&count) if count != 1 { t.Fatalf("流水重复写入! count=%d", count) } } func TestCrossSiteDistributionRebate(t *testing.T) { db := setupTestDB(t) repo := NewRepository(db) ctx := context.Background() // 邀请人 1 (邀请了买家) c1 := "code001" buyerInviter := model.User{Phone: "13900000001", RealnameStatus: "verified", Status: "active", InviteCode: &c1} db.Create(&buyerInviter) // 邀请人 2 (邀请了卖家) c2 := "code002" sellerInviter := model.User{Phone: "13900000002", RealnameStatus: "verified", Status: "active", InviteCode: &c2} db.Create(&sellerInviter) // 买家 now := time.Now() buyer := model.User{Phone: "13900000003", RealnameStatus: "verified", Status: "active"} db.Create(&buyer) db.Create(&model.UserInvitation{InviterID: buyerInviter.ID, InviteeID: buyer.ID, Status: StatusActive, VerifiedAt: &now}) // 卖家 seller := model.User{Phone: "13900000004", RealnameStatus: "verified", Status: "active"} db.Create(&seller) db.Create(&model.UserInvitation{InviterID: sellerInviter.ID, InviteeID: seller.ID, Status: StatusActive, VerifiedAt: &now}) // 分销站点收号拆分: // 主站卖出分站收的账号 (site) // platformSellCent = 4000 (主站利润 40 元) // distributorReceiveCent = 6000 (分站利润 60 元) distSite := model.DistributionSite{ SiteName: "加盟分站88", SiteCode: "site88", Domain: "sub88.example.com", Status: "active", } if err := db.Create(&distSite).Error; err != nil { t.Fatalf("创建测试分销站点失败: %v", err) } distributorSiteID := distSite.ID order := model.RentalOrder{ OrderNo: fmt.Sprintf("ORD-CROSS-%d", time.Now().UnixNano()), RenterID: buyer.ID, OwnerID: seller.ID, Status: "completed", SettlementStatus: "settled", RentAmountCent: 30000, OwnerRentAmountCent: 20000, // 总毛利 10000 } db.Create(&order) if err := db.Create(&model.DistributionOrderParty{ OrderID: order.ID, SiteID: distributorSiteID, PartyRole: "platform_sell", PartyType: "platform", PartyID: 0, ProfitAmountCent: 4000, IncomeAmountCent: 4000, }).Error; err != nil { t.Fatalf("创建平台分成方失败: %v", err) } if err := db.Create(&model.DistributionOrderParty{ OrderID: order.ID, SiteID: distributorSiteID, PartyRole: "distributor_receive", PartyType: "site", PartyID: distributorSiteID, ProfitAmountCent: 6000, IncomeAmountCent: 6000, }).Error; err != nil { t.Fatalf("创建分销分成方失败: %v", err) } if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil { t.Fatalf("跨站分销返利结算失败: %v", err) } // 验证买家端流水:基数 4000 / 2 = 2000,首月 60% = 1200,站点为 0 (主站) var buyerLedger model.InvitationRebateLedger if err := db.Where("order_id = ? AND trade_role = 'buyer'", order.ID).First(&buyerLedger).Error; err != nil { t.Fatalf("未找到买家端流水: %v", err) } if buyerLedger.DistributionSiteID != 0 || buyerLedger.RebateAmountCent != 1200 { t.Fatalf("买家端跨站返利错误: siteID=%d, amount=%d", buyerLedger.DistributionSiteID, buyerLedger.RebateAmountCent) } // 验证卖家端流水:基数 6000 / 2 = 3000,首月 60% = 1800,站点为 88 (分站) var sellerLedger model.InvitationRebateLedger if err := db.Where("order_id = ? AND trade_role = 'seller'", order.ID).First(&sellerLedger).Error; err != nil { t.Fatalf("未找到卖家端流水: %v", err) } if sellerLedger.DistributionSiteID != distributorSiteID || sellerLedger.RebateAmountCent != 1800 { t.Fatalf("卖家端跨站返利错误: siteID=%d, amount=%d", sellerLedger.DistributionSiteID, sellerLedger.RebateAmountCent) } } // cycleLabel 的比例必须来自实际生效配置,改配置后文案要跟着变, // 否则钱包流水备注会与后台比例不一致(历史缺陷)。 func TestCycleLabelFollowsConfiguredRate(t *testing.T) { cases := []struct { cycle string rate float64 want string }{ {CycleFirstMonth, 0.60, "首月奖励 60%"}, {CycleFirstMonth, 0.50, "首月奖励 50%"}, {CycleFirstMonth, 0.80, "首月奖励 80%"}, {CycleSecondMonth, 0.30, "次月奖励 30%"}, {CycleSecondMonth, 0.25, "次月奖励 25%"}, {CycleSubsequent, 0.15, "长期奖励 15%"}, {CycleSubsequent, 0.125, "长期奖励 12.5%"}, {CycleSubsequent, 0, "长期奖励 0%"}, } for _, c := range cases { if got := cycleLabel(c.cycle, c.rate); got != c.want { t.Fatalf("cycleLabel(%s, %v) = %q, want %q", c.cycle, c.rate, got, c.want) } } } // 线下提号返利与租赁订单卖家侧共用同一个入口 AwardSellerSideRebateTx: // 这里验证金额计算正确,且同一来源单据重复调用不会重复入账 // (唯一键 source+order_id+trade_role 保证幂等,补发脚本重跑安全)。 func TestAwardSellerSideRebateTxAwardsOncePerSource(t *testing.T) { db := setupTestDB(t) inviterCode := "inv9001" inviter := model.User{ Phone: "13800009001", Nickname: "提号邀请人", RealnameStatus: "verified", Status: "active", InviteCode: &inviterCode, } if err := db.Create(&inviter).Error; err != nil { t.Fatalf("创建邀请人失败: %v", err) } seller := model.User{ Phone: "13800009002", Nickname: "提号号主", RealnameStatus: "verified", Status: "active", } if err := db.Create(&seller).Error; err != nil { t.Fatalf("创建号主失败: %v", err) } // 距结算 2 天 => 首月 60%。 // Truncate 到整秒:DATETIME(0) 会把小数秒四舍五入,带小数时可能进位成不足 48 小时而算作 1 天。 verifiedAt := time.Now().AddDate(0, 0, -2).Truncate(time.Second) if err := db.Create(&model.UserInvitation{ InviterID: inviter.ID, InviteeID: seller.ID, InviteCode: inviterCode, Status: StatusActive, VerifiedAt: &verifiedAt, }).Error; err != nil { t.Fatalf("创建邀请关系失败: %v", err) } input := SellerSideRebateInput{ Source: RebateSourcePickup, SourceID: 900001, SourceNo: "PK-REBATE-TEST-1", SellerID: seller.ID, GrossBasisCent: 10000, BasisCent: 5000, SiteID: 0, SettledAt: time.Now(), RemarkPrefix: "邀请卖家提号返利", } apply := func() int64 { t.Helper() var amount int64 if err := db.Transaction(func(tx *gorm.DB) error { got, err := AwardSellerSideRebateTx(tx, input) amount = got return err }); err != nil { t.Fatalf("发放提号返利失败: %v", err) } return amount } // 单边基数 5000,首月 60% => 3000 分 if got := apply(); got != 3000 { t.Fatalf("首次发放金额 = %d, want 3000", got) } var rebate model.InvitationRebateLedger if err := db.Where("source = ? AND order_id = ? AND trade_role = 'seller'", RebateSourcePickup, input.SourceID). First(&rebate).Error; err != nil { t.Fatalf("未找到提号返利流水: %v", err) } if rebate.InviterID != inviter.ID || rebate.InviteeID != seller.ID || rebate.OrderNo != input.SourceNo { t.Fatalf("返利流水字段错误: %+v", rebate) } if rebate.CycleStage != CycleFirstMonth || rebate.DaysSinceVerified != 2 { t.Fatalf("返利档位错误: cycle=%s, days=%d", rebate.CycleStage, rebate.DaysSinceVerified) } // 同来源同角色重复调用必须跳过,不重复写流水、不重复入账 if got := apply(); got != 0 { t.Fatalf("重复调用应幂等跳过, got %d", got) } var ledgerCount int64 if err := db.Model(&model.InvitationRebateLedger{}). Where("source = ? AND order_id = ?", RebateSourcePickup, input.SourceID). Count(&ledgerCount).Error; err != nil { t.Fatalf("统计返利流水失败: %v", err) } if ledgerCount != 1 { t.Fatalf("返利流水条数 = %d, want 1", ledgerCount) } var walletCount int64 if err := db.Model(&model.WalletLedger{}). Where("biz_type = ? AND biz_no = ?", "invite_rebate", input.SourceNo). Count(&walletCount).Error; err != nil { t.Fatalf("统计钱包流水失败: %v", err) } if walletCount != 1 { t.Fatalf("钱包流水条数 = %d, want 1", walletCount) } var account model.WalletAccount if err := db.Where("user_id = ? AND distribution_site_id = 0", inviter.ID).First(&account).Error; err != nil { t.Fatalf("未找到邀请人钱包账户: %v", err) } if account.AvailableBalanceCent != 3000 { t.Fatalf("邀请人可用余额 = %d, want 3000", account.AvailableBalanceCent) } }