Files
hfb_sys/backend/internal/modules/invitation/repository_test.go
T
yml2213 9a52dbdb7b test(invitation): 修正返利单测因 DATETIME(0) 小数秒四舍五入导致的天数抖动
MySQL DATETIME(0) 会把小数秒四舍五入,now-2天 可能进位成不足 48 小时而被算作 1 天,导致用例偶发失败;统一 Truncate 到整秒。
2026-10-13 15:21:14 +08:00

432 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}