diff --git a/backend/internal/model/invitation.go b/backend/internal/model/invitation.go index 1421073..03a62c6 100644 --- a/backend/internal/model/invitation.go +++ b/backend/internal/model/invitation.go @@ -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"` } diff --git a/backend/internal/modules/invitation/combinations_test.go b/backend/internal/modules/invitation/combinations_test.go new file mode 100644 index 0000000..f59ff35 --- /dev/null +++ b/backend/internal/modules/invitation/combinations_test.go @@ -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)) + } + }) +} diff --git a/backend/internal/modules/invitation/repository.go b/backend/internal/modules/invitation/repository.go index 6ced5b7..92de7b9 100644 --- a/backend/internal/modules/invitation/repository.go +++ b/backend/internal/modules/invitation/repository.go @@ -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