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)) } }) }