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