test(invitation): 建立邀请返利全状态全组合正交测试套件并修复布尔零值持久化缺陷

- 设计并落地 6 大测试组共 37 个组合场景(绑定准入、实名生命周期、周期滑窗阶梯、双边角色矩阵、分站独立继承、金额极值与幂等性)
- 修复 GORM 在 default:true 标签下忽略布尔零值导致分站关闭返利失效的缺陷
- 在 UpdateRebateConfig 中使用显式 Select 保证 Enabled 字段 100% 精确持久化
- 验证方式:go test -v ./internal/modules/invitation/... 全部通过,耗时约 1.2s
This commit is contained in:
yml2213
2026-10-10 14:07:48 +08:00
parent f5202c6080
commit f19c9f62b7
3 changed files with 821 additions and 3 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ type InvitationRebateConfig struct {
FirstMonthRate float64 `gorm:"type:decimal(6,4);not null;default:0.6000" json:"first_month_rate"`
SecondMonthRate float64 `gorm:"type:decimal(6,4);not null;default:0.3000" json:"second_month_rate"`
SubsequentRate float64 `gorm:"type:decimal(6,4);not null;default:0.1500" json:"subsequent_rate"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
Enabled bool `gorm:"not null" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -0,0 +1,818 @@
package invitation
import (
"context"
"fmt"
"testing"
"time"
"hfb_sys/backend/internal/model"
)
// =============================================================================
// 组合测试组 1: 邀请码绑定与实名准入全组合矩阵
// =============================================================================
func TestCombinations_BindingAndAccessControl(t *testing.T) {
db := setupTestDB(t)
repo := NewRepository(db)
ctx := context.Background()
// 1.1 正常组合:邀请人已实名 + 被邀请人未实名 -> 绑定成功,状态 Pending
t.Run("Valid_VerifiedInviter_UnverifiedInvitee", func(t *testing.T) {
code := "code_valid_1"
inviter := model.User{Phone: "13100000001", RealnameStatus: "verified", Status: "active", InviteCode: &code}
db.Create(&inviter)
invitee := model.User{Phone: "13100000002", RealnameStatus: "unverified", Status: "active"}
db.Create(&invitee)
err := repo.BindInvitation(ctx, invitee.ID, code, 0)
if err != nil {
t.Fatalf("正常绑定应成功,实际错误: %v", err)
}
var record model.UserInvitation
if err := db.Where("invitee_id = ?", invitee.ID).First(&record).Error; err != nil {
t.Fatalf("未找到邀请记录: %v", err)
}
if record.Status != StatusPending || record.VerifiedAt != nil {
t.Fatalf("未实名被邀请人状态应为 pending 且无 verified_at,实际: status=%s, verified_at=%v", record.Status, record.VerifiedAt)
}
})
// 1.2 即时激活组合:邀请人已实名 + 被邀请人注册时已实名 -> 绑定成功且直接生效 Active
t.Run("Valid_VerifiedInviter_AlreadyVerifiedInvitee", func(t *testing.T) {
code := "code_valid_2"
inviter := model.User{Phone: "13100000003", RealnameStatus: "verified", Status: "active", InviteCode: &code}
db.Create(&inviter)
invitee := model.User{Phone: "13100000004", RealnameStatus: "verified", Status: "active"}
db.Create(&invitee)
err := repo.BindInvitation(ctx, invitee.ID, code, 0)
if err != nil {
t.Fatalf("已实名用户绑定应成功: %v", err)
}
var record model.UserInvitation
if err := db.Where("invitee_id = ?", invitee.ID).First(&record).Error; err != nil {
t.Fatalf("未找到邀请记录: %v", err)
}
if record.Status != StatusActive || record.VerifiedAt == nil {
t.Fatalf("已实名用户绑定应直接为 active 且有 verified_at,实际: status=%s, verified_at=%v", record.Status, record.VerifiedAt)
}
})
// 1.3 自邀防御组合:自己邀请自己 -> 拒绝
t.Run("Invalid_SelfInvite", func(t *testing.T) {
code := "code_self"
user := model.User{Phone: "13100000005", RealnameStatus: "verified", Status: "active", InviteCode: &code}
db.Create(&user)
err := repo.BindInvitation(ctx, user.ID, code, 0)
if err != ErrSelfInviteNotAllowed {
t.Fatalf("自己邀请自己应返回 ErrSelfInviteNotAllowed,实际: %v", err)
}
})
// 1.4 邀请人未实名组合:邀请人未实名 -> 拒绝绑定
t.Run("Invalid_UnverifiedInviter", func(t *testing.T) {
code := "code_unverified_inviter"
inviter := model.User{Phone: "13100000006", RealnameStatus: "unverified", Status: "active", InviteCode: &code}
db.Create(&inviter)
invitee := model.User{Phone: "13100000007", RealnameStatus: "unverified", Status: "active"}
db.Create(&invitee)
err := repo.BindInvitation(ctx, invitee.ID, code, 0)
if err != ErrInviterNotVerified {
t.Fatalf("未实名邀请人应返回 ErrInviterNotVerified,实际: %v", err)
}
})
// 1.5 邀请码不存在组合 -> 返回 ErrInvalidInviteCode
t.Run("Invalid_NonExistentCode", func(t *testing.T) {
invitee := model.User{Phone: "13100000008", RealnameStatus: "unverified", Status: "active"}
db.Create(&invitee)
err := repo.BindInvitation(ctx, invitee.ID, "not_exist_code_999", 0)
if err != ErrInvalidInviteCode {
t.Fatalf("不存在的邀请码应返回 ErrInvalidInviteCode,实际: %v", err)
}
})
// 1.6 重复绑定组合:被邀请人不可改绑已存在的邀请人
t.Run("Invalid_AlreadyInvited", func(t *testing.T) {
codeA := "code_dup_a"
codeB := "code_dup_b"
inviterA := model.User{Phone: "13100000009", RealnameStatus: "verified", Status: "active", InviteCode: &codeA}
inviterB := model.User{Phone: "13100000010", RealnameStatus: "verified", Status: "active", InviteCode: &codeB}
db.Create(&inviterA)
db.Create(&inviterB)
invitee := model.User{Phone: "13100000011", RealnameStatus: "unverified", Status: "active"}
db.Create(&invitee)
// 首次绑定 A 成功
if err := repo.BindInvitation(ctx, invitee.ID, codeA, 0); err != nil {
t.Fatalf("初次绑定应成功: %v", err)
}
// 尝试二次绑定 B 应被拒绝
err := repo.BindInvitation(ctx, invitee.ID, codeB, 0)
if err != ErrAlreadyInvited {
t.Fatalf("已存在邀请关系二次绑定应返回 ErrAlreadyInvited,实际: %v", err)
}
})
// 1.7 空邀请码组合 -> 幂等安全返回 nil,不写记录
t.Run("EmptyCode_SafeNoop", func(t *testing.T) {
invitee := model.User{Phone: "13100000012", RealnameStatus: "unverified", Status: "active"}
db.Create(&invitee)
if err := repo.BindInvitation(ctx, invitee.ID, "", 0); err != nil {
t.Fatalf("空邀请码应安全返回 nil: %v", err)
}
if err := repo.BindInvitation(ctx, invitee.ID, " ", 0); err != nil {
t.Fatalf("空白字符邀请码应安全返回 nil: %v", err)
}
var count int64
db.Model(&model.UserInvitation{}).Where("invitee_id = ?", invitee.ID).Count(&count)
if count != 0 {
t.Fatalf("空邀请码不应产生邀请记录")
}
})
// 1.8 分站归属组合:传入分站 siteCode 自动解析对应 DistributionSiteID
t.Run("SiteBinding_BySiteCode", func(t *testing.T) {
site := model.DistributionSite{SiteName: "广州分站", SiteCode: "gz_sub", Domain: "gz_sub.test.local", Status: "active"}
db.Create(&site)
code := "code_site_test"
inviter := model.User{Phone: "13100000013", RealnameStatus: "verified", Status: "active", InviteCode: &code}
db.Create(&inviter)
invitee := model.User{Phone: "13100000014", RealnameStatus: "unverified", Status: "active"}
db.Create(&invitee)
err := repo.BindInvitation(ctx, invitee.ID, code, 0, "gz_sub")
if err != nil {
t.Fatalf("带 siteCode 绑定应成功: %v", err)
}
var record model.UserInvitation
db.Where("invitee_id = ?", invitee.ID).First(&record)
if record.DistributionSiteID != site.ID {
t.Fatalf("分站 ID 映射错误: expected=%d, got=%d", site.ID, record.DistributionSiteID)
}
})
}
// =============================================================================
// 组合测试组 2: 被邀请人实名认证生命周期与邀请码自生
// =============================================================================
func TestCombinations_RealnameLifecycleAndSelfInviteCode(t *testing.T) {
db := setupTestDB(t)
repo := NewRepository(db)
ctx := context.Background()
code := "inv_lifecycle_root"
inviter := model.User{Phone: "13200000001", RealnameStatus: "verified", Status: "active", InviteCode: &code}
db.Create(&inviter)
invitee := model.User{Phone: "13200000002", RealnameStatus: "unverified", Status: "active"}
db.Create(&invitee)
// 绑定,当前为 pending
if err := repo.BindInvitation(ctx, invitee.ID, code, 0); err != nil {
t.Fatalf("绑定失败: %v", err)
}
// 2.1 触发实名认证前状态核对
var record model.UserInvitation
db.Where("invitee_id = ?", invitee.ID).First(&record)
if record.Status != StatusPending || record.VerifiedAt != nil {
t.Fatalf("实名前应为 pending")
}
// 2.2 触发实名生效钩子
db.Model(&model.User{}).Where("id = ?", invitee.ID).Update("realname_status", "verified")
if err := repo.OnUserRealnameVerified(ctx, invitee.ID); err != nil {
t.Fatalf("实名回调执行失败: %v", err)
}
// 验证邀请关系翻转为 Active 并记录 VerifiedAt
db.Where("invitee_id = ?", invitee.ID).First(&record)
if record.Status != StatusActive || record.VerifiedAt == nil {
t.Fatalf("实名后状态应翻转为 active 并记录 verified_at")
}
firstVerifiedAt := *record.VerifiedAt
// 验证被邀请人自身是否自动生成了专属邀请码
var updatedInvitee model.User
db.First(&updatedInvitee, invitee.ID)
if updatedInvitee.InviteCode == nil || len(*updatedInvitee.InviteCode) < 6 {
t.Fatalf("实名后自身应自动生成合格邀请码: %v", updatedInvitee.InviteCode)
}
selfCode := *updatedInvitee.InviteCode
// 2.3 实名回调幂等性:重复调用不破坏 VerifiedAt 时间戳,不重置自身邀请码
time.Sleep(10 * time.Millisecond)
if err := repo.OnUserRealnameVerified(ctx, invitee.ID); err != nil {
t.Fatalf("重复实名回调应安全: %v", err)
}
db.Where("invitee_id = ?", invitee.ID).First(&record)
if !record.VerifiedAt.Equal(firstVerifiedAt) {
t.Fatalf("重复调用不应窜改原始 verified_at 时间戳")
}
db.First(&updatedInvitee, invitee.ID)
if *updatedInvitee.InviteCode != selfCode {
t.Fatalf("重复调用不应覆盖已有自身邀请码")
}
}
// =============================================================================
// 组合测试组 3: 周期阶梯判定与时间滑窗组合全覆盖 (表驱动测试)
// =============================================================================
func TestCombinations_CycleStageSlidingWindow(t *testing.T) {
now := time.Now()
customCfg := &model.InvitationRebateConfig{
FirstMonthRate: 0.80,
SecondMonthRate: 0.40,
SubsequentRate: 0.20,
Enabled: true,
}
cases := []struct {
name string
daysAgo int
hoursDelta time.Duration
cfg *model.InvitationRebateConfig
expectedCycle string
expectedRate float64
}{
// 默认比例测试组 (0.60, 0.30, 0.15)
{name: "首日_0天", daysAgo: 0, expectedCycle: CycleFirstMonth, expectedRate: 0.60},
{name: "首月_15天中段", daysAgo: 15, expectedCycle: CycleFirstMonth, expectedRate: 0.60},
{name: "首月临界_30天整", daysAgo: 30, expectedCycle: CycleFirstMonth, expectedRate: 0.60},
{name: "次月临界突破_31天", daysAgo: 31, expectedCycle: CycleSecondMonth, expectedRate: 0.30},
{name: "次月_45天中段", daysAgo: 45, expectedCycle: CycleSecondMonth, expectedRate: 0.30},
{name: "次月临界_60天整", daysAgo: 60, expectedCycle: CycleSecondMonth, expectedRate: 0.30},
{name: "长期临界突破_61天", daysAgo: 61, expectedCycle: CycleSubsequent, expectedRate: 0.15},
{name: "长期_180天半年", daysAgo: 180, expectedCycle: CycleSubsequent, expectedRate: 0.15},
{name: "长期_365天一年", daysAgo: 365, expectedCycle: CycleSubsequent, expectedRate: 0.15},
// 时钟回退防御
{name: "时钟回退_未来时间", daysAgo: -5, expectedCycle: CycleFirstMonth, expectedRate: 0.60},
// 自定义配置比例测试组 (0.80, 0.40, 0.20)
{name: "自定义_首月", daysAgo: 10, cfg: customCfg, expectedCycle: CycleFirstMonth, expectedRate: 0.80},
{name: "自定义_次月", daysAgo: 40, cfg: customCfg, expectedCycle: CycleSecondMonth, expectedRate: 0.40},
{name: "自定义_长期", daysAgo: 100, cfg: customCfg, expectedCycle: CycleSubsequent, expectedRate: 0.20},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
verifiedAt := now.AddDate(0, 0, -tc.daysAgo).Add(-tc.hoursDelta)
rate, cycle, days := CalculateRebateRate(verifiedAt, now, tc.cfg)
if cycle != tc.expectedCycle {
t.Errorf("[%s] 阶段不匹配: expected=%s, got=%s (days=%d)", tc.name, tc.expectedCycle, cycle, days)
}
if rate != tc.expectedRate {
t.Errorf("[%s] 比例不匹配: expected=%.2f, got=%.2f", tc.name, tc.expectedRate, rate)
}
})
}
}
// =============================================================================
// 组合测试组 4: 交易角色双边覆盖所有组合 (买家单边/卖家单边/双边不同/双边同人叠加通吃/双方无邀请/未实名)
// =============================================================================
func TestCombinations_TradeRolesMatrix(t *testing.T) {
db := setupTestDB(t)
repo := NewRepository(db)
ctx := context.Background()
now := time.Now()
// 创建三个独立的合格邀请人 A, B, C
codeA, codeB, codeC := "inv_role_A", "inv_role_B", "inv_role_C"
inviterA := model.User{Phone: "13300000001", RealnameStatus: "verified", Status: "active", InviteCode: &codeA}
inviterB := model.User{Phone: "13300000002", RealnameStatus: "verified", Status: "active", InviteCode: &codeB}
inviterC := model.User{Phone: "13300000003", RealnameStatus: "verified", Status: "active", InviteCode: &codeC}
db.Create(&inviterA)
db.Create(&inviterB)
db.Create(&inviterC)
// 辅助创建订单工具
createOrder := func(renterID, ownerID uint64, rentCent, ownerCent int64) model.RentalOrder {
order := model.RentalOrder{
OrderNo: fmt.Sprintf("ORD-COMB-%d", time.Now().UnixNano()),
RenterID: renterID,
OwnerID: ownerID,
Status: "completed",
SettlementStatus: "settled",
RentAmountCent: rentCent,
OwnerRentAmountCent: ownerCent,
}
db.Create(&order)
return order
}
// 4.1 组合一:仅买家有邀请人 (BuyerOnly)
t.Run("Combo_BuyerOnly", func(t *testing.T) {
buyer := model.User{Phone: "13310000001", RealnameStatus: "verified", Status: "active"}
seller := model.User{Phone: "13310000002", RealnameStatus: "verified", Status: "active"}
db.Create(&buyer)
db.Create(&seller)
// 绑定买家给 A (首月 10 天)
vAt := now.AddDate(0, 0, -10)
db.Create(&model.UserInvitation{InviterID: inviterA.ID, InviteeID: buyer.ID, Status: StatusActive, VerifiedAt: &vAt})
// 订单毛利 200元 (单边基准 100元,首月 60% = 60元)
order := createOrder(buyer.ID, seller.ID, 30000, 10000)
if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil {
t.Fatalf("结算失败: %v", err)
}
var ledgers []model.InvitationRebateLedger
db.Where("order_id = ?", order.ID).Find(&ledgers)
if len(ledgers) != 1 {
t.Fatalf("仅买家有邀请人应生成 1 条流水,实际: %d", len(ledgers))
}
if ledgers[0].TradeRole != "buyer" || ledgers[0].InviterID != inviterA.ID || ledgers[0].RebateAmountCent != 6000 {
t.Fatalf("买家单边返利不符: %+v", ledgers[0])
}
})
// 4.2 组合二:仅卖家有邀请人 (SellerOnly)
t.Run("Combo_SellerOnly", func(t *testing.T) {
buyer := model.User{Phone: "13320000001", RealnameStatus: "verified", Status: "active"}
seller := model.User{Phone: "13320000002", RealnameStatus: "verified", Status: "active"}
db.Create(&buyer)
db.Create(&seller)
// 绑定卖家给 B (次月 40 天,比例 30%)
vAt := now.AddDate(0, 0, -40)
db.Create(&model.UserInvitation{InviterID: inviterB.ID, InviteeID: seller.ID, Status: StatusActive, VerifiedAt: &vAt})
// 订单毛利 200元 (单边基准 100元,次月 30% = 30元)
order := createOrder(buyer.ID, seller.ID, 30000, 10000)
if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil {
t.Fatalf("结算失败: %v", err)
}
var ledgers []model.InvitationRebateLedger
db.Where("order_id = ?", order.ID).Find(&ledgers)
if len(ledgers) != 1 {
t.Fatalf("仅卖家有邀请人应生成 1 条流水,实际: %d", len(ledgers))
}
if ledgers[0].TradeRole != "seller" || ledgers[0].InviterID != inviterB.ID || ledgers[0].RebateAmountCent != 3000 {
t.Fatalf("卖家单边返利不符: %+v", ledgers[0])
}
})
// 4.3 组合三:买卖双方分别有不同邀请人 (DualDistinct: A 拿买家端,B 拿卖家端)
t.Run("Combo_DualDistinctInviters", func(t *testing.T) {
buyer := model.User{Phone: "13330000001", RealnameStatus: "verified", Status: "active"}
seller := model.User{Phone: "13330000002", RealnameStatus: "verified", Status: "active"}
db.Create(&buyer)
db.Create(&seller)
vBuyer := now.AddDate(0, 0, -5) // 买家首月 60%
vSeller := now.AddDate(0, 0, -70) // 卖家长期 15%
db.Create(&model.UserInvitation{InviterID: inviterA.ID, InviteeID: buyer.ID, Status: StatusActive, VerifiedAt: &vBuyer})
db.Create(&model.UserInvitation{InviterID: inviterB.ID, InviteeID: seller.ID, Status: StatusActive, VerifiedAt: &vSeller})
// 订单毛利 200元 (单边基准 100元)
// 买家返利: 100 * 0.6 = 60元 (6000分) 给 A
// 卖家返利: 100 * 0.15 = 15元 (1500分) 给 B
order := createOrder(buyer.ID, seller.ID, 30000, 10000)
if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil {
t.Fatalf("结算失败: %v", err)
}
var ledgers []model.InvitationRebateLedger
db.Where("order_id = ?", order.ID).Order("trade_role asc").Find(&ledgers)
if len(ledgers) != 2 {
t.Fatalf("双边应生成 2 条流水,实际: %d", len(ledgers))
}
// buyer
if ledgers[0].TradeRole != "buyer" || ledgers[0].InviterID != inviterA.ID || ledgers[0].RebateAmountCent != 6000 {
t.Fatalf("买家端流水有误: %+v", ledgers[0])
}
// seller
if ledgers[1].TradeRole != "seller" || ledgers[1].InviterID != inviterB.ID || ledgers[1].RebateAmountCent != 1500 {
t.Fatalf("卖家端流水有误: %+v", ledgers[1])
}
})
// 4.4 组合四:买卖双方为同一邀请人的好友 (DualSame: A 独揽双边叠加通吃)
t.Run("Combo_DualSameInviter_DoubleBonus", func(t *testing.T) {
buyer := model.User{Phone: "13340000001", RealnameStatus: "verified", Status: "active"}
seller := model.User{Phone: "13340000002", RealnameStatus: "verified", Status: "active"}
db.Create(&buyer)
db.Create(&seller)
vBuyer := now.AddDate(0, 0, -10) // 买家首月 60%
vSeller := now.AddDate(0, 0, -20) // 卖家首月 60%
// 两个人都归 C 邀请
db.Create(&model.UserInvitation{InviterID: inviterC.ID, InviteeID: buyer.ID, Status: StatusActive, VerifiedAt: &vBuyer})
db.Create(&model.UserInvitation{InviterID: inviterC.ID, InviteeID: seller.ID, Status: StatusActive, VerifiedAt: &vSeller})
// 订单毛利 200元 (买卖两端基准各 100元)
// C 获得买家端 60元 + 卖家端 60元 = 合计 120元 (12000分)
order := createOrder(buyer.ID, seller.ID, 30000, 10000)
if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil {
t.Fatalf("结算失败: %v", err)
}
var ledgers []model.InvitationRebateLedger
db.Where("order_id = ?", order.ID).Find(&ledgers)
if len(ledgers) != 2 {
t.Fatalf("同一人邀请买卖双方应生成 2 条独立流水,实际: %d", len(ledgers))
}
for _, l := range ledgers {
if l.InviterID != inviterC.ID || l.RebateAmountCent != 6000 {
t.Fatalf("流水信息不符: %+v", l)
}
}
// 验证 C 的钱包余额应收到 12000 分
var walletAcct model.WalletAccount
db.Where("user_id = ? AND distribution_site_id = 0", inviterC.ID).First(&walletAcct)
if walletAcct.AvailableBalanceCent != 12000 {
t.Fatalf("C 的双边叠加可用余额应为 12000分,实际: %d", walletAcct.AvailableBalanceCent)
}
})
// 4.5 组合五:双方均无邀请人 (Neither) -> 0 条流水
t.Run("Combo_NeitherInvited", func(t *testing.T) {
buyer := model.User{Phone: "13350000001", RealnameStatus: "verified", Status: "active"}
seller := model.User{Phone: "13350000002", RealnameStatus: "verified", Status: "active"}
db.Create(&buyer)
db.Create(&seller)
order := createOrder(buyer.ID, seller.ID, 30000, 10000)
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 != 0 {
t.Fatalf("无邀请关系不应产生流水,实际 count=%d", count)
}
})
// 4.6 组合六:被邀请人未实名认证 (StatusPending) -> 不产生返利
t.Run("Combo_InviteeNotVerified_NoRebate", func(t *testing.T) {
buyer := model.User{Phone: "13360000001", RealnameStatus: "unverified", Status: "active"}
seller := model.User{Phone: "13360000002", RealnameStatus: "verified", Status: "active"}
db.Create(&buyer)
db.Create(&seller)
// 绑定处于 pending,VerifiedAt 为 nil
db.Create(&model.UserInvitation{InviterID: inviterA.ID, InviteeID: buyer.ID, Status: StatusPending, VerifiedAt: nil})
order := createOrder(buyer.ID, seller.ID, 30000, 10000)
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 != 0 {
t.Fatalf("未实名好友产生交易时不应发放返利")
}
})
// 4.7 组合七:邀请人已失信/未实名 -> 不发放返利
t.Run("Combo_InviterLostVerification_NoRebate", func(t *testing.T) {
unvCode := "inv_unv_person"
fakeInviter := model.User{Phone: "13370000001", RealnameStatus: "unverified", Status: "active", InviteCode: &unvCode}
db.Create(&fakeInviter)
buyer := model.User{Phone: "13370000002", RealnameStatus: "verified", Status: "active"}
seller := model.User{Phone: "13370000003", RealnameStatus: "verified", Status: "active"}
db.Create(&buyer)
db.Create(&seller)
vAt := now.AddDate(0, 0, -5)
db.Create(&model.UserInvitation{InviterID: fakeInviter.ID, InviteeID: buyer.ID, Status: StatusActive, VerifiedAt: &vAt})
order := createOrder(buyer.ID, seller.ID, 30000, 10000)
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 != 0 {
t.Fatalf("邀请人处于未实名状态时不应发放返利")
}
})
// 4.8 组合八:邀请人自身作为买家(自买自卖防御) -> 拒绝自我返利
t.Run("Combo_SelfPurchaseDefense", func(t *testing.T) {
seller := model.User{Phone: "13380000001", RealnameStatus: "verified", Status: "active"}
db.Create(&seller)
// A 购买,且 A 是自己的邀请关系(异常数据防御)
vAt := now.AddDate(0, 0, -5)
db.Create(&model.UserInvitation{InviterID: inviterA.ID, InviteeID: inviterA.ID, Status: StatusActive, VerifiedAt: &vAt})
order := createOrder(inviterA.ID, seller.ID, 30000, 10000)
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 != 0 {
t.Fatalf("邀请人自身购买不应获得买家端自我返利")
}
})
}
// =============================================================================
// 组合测试组 5: 分站独立配置与继承组合 (默认继承/自定义比例/分站关闭返利/跨站拆分)
// =============================================================================
func TestCombinations_MultiTenantSiteConfigs(t *testing.T) {
db := setupTestDB(t)
repo := NewRepository(db)
ctx := context.Background()
now := time.Now()
// 创建两个分站:站点 101 (自定义比例), 站点 102 (关闭返利)
site101 := model.DistributionSite{SiteName: "成都分站", SiteCode: "site_101", Domain: "site101.test.local", Status: "active"}
site102 := model.DistributionSite{SiteName: "武汉分站", SiteCode: "site_102", Domain: "site102.test.local", Status: "active"}
if err := db.Create(&site101).Error; err != nil {
t.Fatalf("创建站点 101 失败: %v", err)
}
if err := db.Create(&site102).Error; err != nil {
t.Fatalf("创建站点 102 失败: %v", err)
}
// 站点 101 配置: 首月 50%, 次月 25%, 长期 10%
enabledTrue := true
_, err := repo.UpdateRebateConfig(ctx, site101.ID, UpdateConfigRequest{
FirstMonthRate: 0.50,
SecondMonthRate: 0.25,
SubsequentRate: 0.10,
Enabled: &enabledTrue,
})
if err != nil {
t.Fatalf("配置站点 101 失败: %v", err)
}
// 站点 102 配置: 关闭返利 Enabled = false
enabledFalse := false
_, err = repo.UpdateRebateConfig(ctx, site102.ID, UpdateConfigRequest{
FirstMonthRate: 0.60,
SecondMonthRate: 0.30,
SubsequentRate: 0.15,
Enabled: &enabledFalse,
})
if err != nil {
t.Fatalf("配置站点 102 失败: %v", err)
}
codeP := "inv_dist_P"
inviter := model.User{Phone: "13400000001", RealnameStatus: "verified", Status: "active", InviteCode: &codeP}
db.Create(&inviter)
// 5.1 组合一:分站自定义比例 (Site 101 订单应按 50% 结算首月)
t.Run("Subsite_CustomRatesApplied", func(t *testing.T) {
buyer := model.User{Phone: "13410000001", RealnameStatus: "verified", Status: "active"}
seller := model.User{Phone: "13410000002", RealnameStatus: "verified", Status: "active"}
db.Create(&buyer)
db.Create(&seller)
vAt := now.AddDate(0, 0, -10)
db.Create(&model.UserInvitation{InviterID: inviter.ID, InviteeID: buyer.ID, DistributionSiteID: site101.ID, Status: StatusActive, VerifiedAt: &vAt})
siteID := site101.ID
order := model.RentalOrder{
OrderNo: fmt.Sprintf("ORD-S101-%d", time.Now().UnixNano()),
DistributionSiteID: &siteID,
RenterID: buyer.ID,
OwnerID: seller.ID,
Status: "completed",
SettlementStatus: "settled",
RentAmountCent: 20000,
OwnerRentAmountCent: 10000, // 毛利 100元 (单边基准 50元 = 5000分)
// 首月 50% = 2500分 (25元)
}
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 l model.InvitationRebateLedger
if err := db.Where("order_id = ?", order.ID).First(&l).Error; err != nil {
t.Fatalf("查询流水失败: %v", err)
}
if l.DistributionSiteID != site101.ID || l.RateApplied != 0.50 || l.RebateAmountCent != 2500 {
t.Fatalf("分站 101 返利计算错误: site=%d, rate=%.2f, amount=%d", l.DistributionSiteID, l.RateApplied, l.RebateAmountCent)
}
})
// 5.2 组合二:分站关闭返利开关 (Site 102 订单不产生返利)
t.Run("Subsite_DisabledNoRebate", func(t *testing.T) {
buyer := model.User{Phone: "13420000001", RealnameStatus: "verified", Status: "active"}
seller := model.User{Phone: "13420000002", RealnameStatus: "verified", Status: "active"}
db.Create(&buyer)
db.Create(&seller)
vAt := now.AddDate(0, 0, -10)
db.Create(&model.UserInvitation{InviterID: inviter.ID, InviteeID: buyer.ID, DistributionSiteID: site102.ID, Status: StatusActive, VerifiedAt: &vAt})
siteID := site102.ID
order := model.RentalOrder{
OrderNo: fmt.Sprintf("ORD-S102-%d", time.Now().UnixNano()),
DistributionSiteID: &siteID,
RenterID: buyer.ID,
OwnerID: seller.ID,
Status: "completed",
SettlementStatus: "settled",
RentAmountCent: 20000,
OwnerRentAmountCent: 10000,
}
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 count int64
db.Model(&model.InvitationRebateLedger{}).Where("order_id = ?", order.ID).Count(&count)
if count != 0 {
t.Fatalf("分站已关闭返利时不应产生流水")
}
})
// 5.3 组合三:未独立配置的分站自动回退继承主站全局配置
t.Run("Subsite_FallbackToDefault", func(t *testing.T) {
site999 := model.DistributionSite{SiteName: "新分站999", SiteCode: "site_999", Domain: "site999.test.local", Status: "active"}
db.Create(&site999)
cfg, err := repo.GetRebateConfig(ctx, site999.ID)
if err != nil {
t.Fatalf("获取配置失败: %v", err)
}
if cfg.FirstMonthRate != DefaultFirstMonthRate || cfg.SecondMonthRate != DefaultSecondMonthRate || cfg.SubsequentRate != DefaultSubsequentRate {
t.Fatalf("未配置分站应继承主站默认比例: %+v", cfg)
}
})
}
// =============================================================================
// 组合测试组 6: 订单金额边界、极值精度与幂等性测试 (零毛利/负毛利/1分钱精度/非终态/并发幂等)
// =============================================================================
func TestCombinations_OrderAmountEdgeCasesAndIdempotency(t *testing.T) {
db := setupTestDB(t)
repo := NewRepository(db)
ctx := context.Background()
now := time.Now()
code := "inv_edge_tester"
inviter := model.User{Phone: "13500000001", RealnameStatus: "verified", Status: "active", InviteCode: &code}
db.Create(&inviter)
buyer := model.User{Phone: "13500000002", RealnameStatus: "verified", Status: "active"}
seller := model.User{Phone: "13500000003", RealnameStatus: "verified", Status: "active"}
db.Create(&buyer)
db.Create(&seller)
vAt := now.AddDate(0, 0, -5)
db.Create(&model.UserInvitation{InviterID: inviter.ID, InviteeID: buyer.ID, Status: StatusActive, VerifiedAt: &vAt})
// 6.1 组合一:零毛利订单 (RentAmount == OwnerRentAmount) -> 不发返利
t.Run("ZeroProfitOrder", func(t *testing.T) {
order := model.RentalOrder{
OrderNo: fmt.Sprintf("ORD-ZERO-%d", time.Now().UnixNano()),
RenterID: buyer.ID,
OwnerID: seller.ID,
Status: "completed",
SettlementStatus: "settled",
RentAmountCent: 5000,
OwnerRentAmountCent: 5000, // 毛利 0
}
db.Create(&order)
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 != 0 {
t.Fatalf("零毛利不应产生流水")
}
})
// 6.2 组合二:负毛利订单 (RentAmount < OwnerRentAmount) -> 不发返利
t.Run("NegativeProfitOrder", func(t *testing.T) {
order := model.RentalOrder{
OrderNo: fmt.Sprintf("ORD-NEG-%d", time.Now().UnixNano()),
RenterID: buyer.ID,
OwnerID: seller.ID,
Status: "completed",
SettlementStatus: "settled",
RentAmountCent: 4000,
OwnerRentAmountCent: 5000, // 负毛利 -1000
}
db.Create(&order)
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 != 0 {
t.Fatalf("负毛利不应产生流水")
}
})
// 6.3 组合三:极小毛利 1 分钱精度测试 -> 不 panic,安全退出
t.Run("OneCentPennyProfit", func(t *testing.T) {
order := model.RentalOrder{
OrderNo: fmt.Sprintf("ORD-PENNY-%d", time.Now().UnixNano()),
RenterID: buyer.ID,
OwnerID: seller.ID,
Status: "completed",
SettlementStatus: "settled",
RentAmountCent: 101,
OwnerRentAmountCent: 100, // 毛利 1分钱,单边除以2为0
}
db.Create(&order)
if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil {
t.Fatalf("1分钱订单不应报错: %v", err)
}
})
// 6.4 组合四:非终态订单 (如 renting 进行中 / refunded 已退款) -> 不触发结算
t.Run("NonCompletedOrder", func(t *testing.T) {
orderRenting := model.RentalOrder{
OrderNo: fmt.Sprintf("ORD-RENTING-%d", time.Now().UnixNano()),
RenterID: buyer.ID,
OwnerID: seller.ID,
Status: "renting",
SettlementStatus: "unsettled",
RentAmountCent: 20000,
OwnerRentAmountCent: 10000,
}
db.Create(&orderRenting)
if err := repo.ProcessOrderInvitationRebate(ctx, db, orderRenting.ID); err != nil {
t.Fatalf("未结算订单应安全跳过: %v", err)
}
var count int64
db.Model(&model.InvitationRebateLedger{}).Where("order_id = ?", orderRenting.ID).Count(&count)
if count != 0 {
t.Fatalf("租赁中的订单不应提前结算返利")
}
})
// 6.5 组合五:重复触发结算幂等性测试 (调用 5 次,流水与钱包严格唯一)
t.Run("RepeatedSettlementIdempotency", func(t *testing.T) {
order := model.RentalOrder{
OrderNo: fmt.Sprintf("ORD-IDEMP-%d", time.Now().UnixNano()),
RenterID: buyer.ID,
OwnerID: seller.ID,
Status: "completed",
SettlementStatus: "settled",
RentAmountCent: 20000,
OwnerRentAmountCent: 10000, // 毛利 100元,单边基准 50元,首月 60% = 30元 (3000分)
}
db.Create(&order)
// 连续调用 5 次
for i := 0; i < 5; i++ {
if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil {
t.Fatalf("第 %d 次幂等调用失败: %v", i+1, err)
}
}
// 校验返利流水条数
var ledgers []model.InvitationRebateLedger
db.Where("order_id = ?", order.ID).Find(&ledgers)
if len(ledgers) != 1 {
t.Fatalf("重复调用 5 次流水条数应严格为 1,实际: %d", len(ledgers))
}
if ledgers[0].RebateAmountCent != 3000 {
t.Fatalf("返利金额错误: %d", ledgers[0].RebateAmountCent)
}
// 校验钱包流水条数与余额
var walletLedgers []model.WalletLedger
db.Where("biz_no = ? AND biz_type = 'invite_rebate'", order.OrderNo).Find(&walletLedgers)
if len(walletLedgers) != 1 {
t.Fatalf("钱包流水应严格为 1 条,实际: %d", len(walletLedgers))
}
})
}
@@ -299,7 +299,7 @@ func (r *Repository) UpdateRebateConfig(ctx context.Context, siteID uint64, req
if req.Enabled != nil {
cfg.Enabled = *req.Enabled
}
if err := r.db.WithContext(ctx).Create(&cfg).Error; err != nil {
if err := r.db.WithContext(ctx).Select("DistributionSiteID", "FirstMonthRate", "SecondMonthRate", "SubsequentRate", "Enabled").Create(&cfg).Error; err != nil {
return nil, err
}
return &cfg, nil
@@ -310,7 +310,7 @@ func (r *Repository) UpdateRebateConfig(ctx context.Context, siteID uint64, req
if req.Enabled != nil {
cfg.Enabled = *req.Enabled
}
if err := r.db.WithContext(ctx).Save(&cfg).Error; err != nil {
if err := r.db.WithContext(ctx).Select("FirstMonthRate", "SecondMonthRate", "SubsequentRate", "Enabled").Save(&cfg).Error; err != nil {
return nil, err
}
return &cfg, nil