Files
hfb_sys/backend/internal/modules/invitation/repository_test.go
T
yml2213 984433f866 feat(invitation): 实现邀请返利系统、专属注册落地页与分成管理
- 数据库与数据模型:新增 migration 000081,支持用户专属邀请码、被邀请人绑定与实名时间戳;新增邀请返利配置表与结算明细表
- 业务与结算逻辑:实现首月 60%、次月 30%、往后 15% 阶梯返利算法;买卖双向独立寻源并根据订单平台利润 50% 单边基准核算;与终态订单结算和钱包可用余额强幂等联动
- 分销站点支持:支持主站与加盟分站独立配置返利比例;跨站交易按照收号端与售卖端独立出资,资金互不侵蚀
- 前端交互与入口:
  - 新增专属短链注册落地页 /i/:code,自动带入并锁定邀请码,引导实名认证
  - 个人中心与移动端新增邀请返利看板(专属码、推广链接、统计面板、好友记录与返利流水明细)
  - 管理后台新增邀请返利管理(比例配置、邀请关系与返利流水审计)
- 验证方式:
  - 通过 repository_test.go 单元测试(阶梯区间、实名激活、跨站多方分流验证)
  - 通过 npm run format、npm run check 与 scripts/check.sh 全量门禁校验
2026-10-10 12:48:59 +08:00

292 lines
10 KiB
Go

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