fix(invitation): 线下提号接入邀请返利并补发历史漏发,返利比例文案改读配置

- 线下提号(admin_pickups)不走订单结账流程,此前完全不发邀请返利;现于 Complete() 内按号主利润对半基数发放卖家侧返利
- 迁移 000086:invitation_rebate_ledgers 增加 source 列,唯一键调整为 (source, order_id, trade_role),支持提号来源且保持幂等
- 新增 cmd/rebate-backfill 幂等补发脚本:默认预演,加 -apply 才写库
- 返利阶段文案不再写死 60%/30%/15%,改为按本次实际生效比例生成(钱包流水备注同步修正)
- 前台邀请页阶段标签/横幅/空态文案、移动端个人中心入口文案均改读接口配置,并修掉合法 0% 比例被 || 覆盖成 60% 的问题
- 新增单测:提号返利发放与幂等、cycleLabel 跟随后台配置
This commit is contained in:
yml2213
2026-10-13 15:12:54 +08:00
parent 0d6a7786a4
commit ead58417e0
12 changed files with 658 additions and 22 deletions
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -38,6 +39,10 @@ const (
DefaultFirstMonthRate = 0.6000
DefaultSecondMonthRate = 0.3000
DefaultSubsequentRate = 0.1500
// 返利来源单据类型:source=biz_type 用于区分返利由哪类单据产生。
RebateSourceRentalOrder = "rental_order"
RebateSourcePickup = "pickup"
)
type Repository struct {
@@ -433,7 +438,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
BalanceType: "available",
BizType: "invite_rebate",
BizNo: order.OrderNo,
Remark: fmt.Sprintf("邀请买家成交返利(订单 %s,%s)", order.OrderNo, cycleLabel(cycle)),
Remark: fmt.Sprintf("邀请买家成交返利(订单 %s,%s)", order.OrderNo, cycleLabel(cycle, rate)),
})
}
}
@@ -495,7 +500,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
BalanceType: "available",
BizType: "invite_rebate",
BizNo: order.OrderNo,
Remark: fmt.Sprintf("邀请卖家成交返利(订单 %s,%s)", order.OrderNo, cycleLabel(cycle)),
Remark: fmt.Sprintf("邀请卖家成交返利(订单 %s,%s)", order.OrderNo, cycleLabel(cycle, rate)),
})
}
}
@@ -508,17 +513,137 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
return nil
}
func cycleLabel(cycle string) string {
// cycleLabel 生成返利周期的展示文案。
// 比例必须取本次实际生效的 rate(后台/分销站可改配置),
// 不能写死 60%/30%/15%,否则钱包流水备注会和实际比例不一致。
func cycleLabel(cycle string, rate float64) string {
percent := formatRebateRate(rate)
switch cycle {
case CycleFirstMonth:
return "首月高额奖励 60%"
return "首月奖励 " + percent
case CycleSecondMonth:
return "次月奖励 30%"
return "次月奖励 " + percent
case CycleSubsequent:
return "长期奖励 " + percent
default:
return "长期奖励 15%"
return "返利 " + percent
}
}
// formatRebateRate 把比例(0.6)格式化为展示文案(60%)。
// 先按配置列精度 decimal(6,4) 规整,避免浮点尾差显示成 59.999999%。
func formatRebateRate(rate float64) string {
percent := math.Round(rate*10000) / 100
return strconv.FormatFloat(percent, 'f', -1, 64) + "%"
}
// SellerSideRebateInput 单边(卖家/服务提供方)返利结算入参。
//
// 租赁订单与线下提号共用同一套规则:按被邀请人实名生效时刻计算比例阶梯、
// 用 (source, order_id, trade_role) 唯一键保证幂等、入账到邀请人钱包可用余额。
// 差异只在来源标识与收益基数,因此统一走这里,避免两处各写一遍算钱逻辑。
type SellerSideRebateInput struct {
Source string // 来源单据类型:RebateSourceRentalOrder / RebateSourcePickup
SourceID uint64 // 来源单据 ID
SourceNo string // 来源单据编号(写入返利流水与钱包流水备注)
SellerID uint64 // 交易人(被邀请人本人)
GrossBasisCent int64 // 来源单据整体收益基数(分)
BasisCent int64 // 单边收益基数(分),通常为 GrossBasisCent 对半
SiteID uint64 // 成本分担站点(0=主站)
RateReferenceAt time.Time // 比例阶梯的基准时刻(单据完成时刻);零值取当前时间
SettledAt time.Time // 实际入账时刻;零值取当前时间
RemarkPrefix string // 钱包流水备注前缀
}
// AwardSellerSideRebateTx 在事务内为「交易人的邀请人」发放单边返利(幂等)。
// 未命中生效邀请关系、邀请人未实名、返利配置关闭或金额为 0 时不写任何记录并返回 0。
func AwardSellerSideRebateTx(tx *gorm.DB, in SellerSideRebateInput) (int64, error) {
if tx == nil || in.SellerID == 0 || in.SourceID == 0 || in.BasisCent <= 0 {
return 0, nil
}
ctx := context.Background()
var invitation model.UserInvitation
if err := tx.WithContext(ctx).
Where("invitee_id = ? AND status = ?", in.SellerID, StatusActive).
First(&invitation).Error; err != nil {
return 0, nil
}
if invitation.VerifiedAt == nil {
return 0, nil
}
var inviter model.User
if err := tx.WithContext(ctx).First(&inviter, invitation.InviterID).Error; err != nil {
return 0, nil
}
if inviter.RealnameStatus != "verified" || inviter.ID == in.SellerID {
return 0, nil
}
repo := NewRepository(tx)
cfg, _ := repo.GetRebateConfig(ctx, in.SiteID)
if cfg != nil && !cfg.Enabled {
return 0, nil
}
referenceAt := in.RateReferenceAt
if referenceAt.IsZero() {
referenceAt = time.Now()
}
settledAt := in.SettledAt
if settledAt.IsZero() {
settledAt = time.Now()
}
rate, cycle, days := CalculateRebateRate(*invitation.VerifiedAt, referenceAt, cfg)
rebateCent := int64(math.Round(float64(in.BasisCent) * rate))
if rebateCent <= 0 {
return 0, nil
}
grossBasis := in.GrossBasisCent
if grossBasis <= 0 {
grossBasis = in.BasisCent
}
ledger := model.InvitationRebateLedger{
OrderID: in.SourceID,
OrderNo: in.SourceNo,
Source: in.Source,
DistributionSiteID: in.SiteID,
InviterID: invitation.InviterID,
InviteeID: in.SellerID,
TradeRole: "seller",
OrderProfitBasisCent: grossBasis,
SingleSideBasisCent: in.BasisCent,
RateApplied: rate,
DaysSinceVerified: days,
CycleStage: cycle,
RebateAmountCent: rebateCent,
WalletLedgerNo: fmt.Sprintf("REB-S-%s-%d", in.SourceNo, invitation.InviterID),
Status: "settled",
SettledAt: settledAt,
}
if err := tx.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&ledger).Error; err != nil {
return 0, err
}
if ledger.ID == 0 {
// 同来源同角色已有返利记录:幂等跳过(重复调用、补发脚本重跑均安全)。
return 0, nil
}
if err := wallet.AppendEntries(tx, wallet.Entry{
UserID: invitation.InviterID,
DistributionSiteID: in.SiteID,
Direction: "in",
AmountCent: rebateCent,
BalanceType: "available",
BizType: "invite_rebate",
BizNo: in.SourceNo,
Remark: fmt.Sprintf("%s(%s,%s)", in.RemarkPrefix, in.SourceNo, cycleLabel(cycle, rate)),
}); err != nil {
return 0, err
}
return rebateCent, nil
}
// GetUserSummary 获取用户个人中心的邀请返利数据看板
func (r *Repository) GetUserSummary(ctx context.Context, userID uint64, siteID uint64) (*UserInvitationSummaryDTO, error) {
if r == nil || r.db == nil {
@@ -835,6 +960,7 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba
ID uint64 `gorm:"column:id"`
OrderID uint64 `gorm:"column:order_id"`
OrderNo string `gorm:"column:order_no"`
Source string `gorm:"column:source"`
DistributionSiteID uint64 `gorm:"column:distribution_site_id"`
InviterID uint64 `gorm:"column:inviter_id"`
InviterPhone string `gorm:"column:inviter_phone"`
@@ -853,7 +979,7 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba
var rows []rawLedger
offset := (query.Page - 1) * query.PageSize
err := db.Select("irl.id, irl.order_id, irl.order_no, irl.distribution_site_id, irl.inviter_id, inviter.phone as inviter_phone, irl.invitee_id, invitee.phone as invitee_phone, irl.trade_role, irl.order_profit_basis_cent, irl.single_side_basis_cent, irl.rate_applied, irl.days_since_verified, irl.cycle_stage, irl.rebate_amount_cent, irl.status, irl.settled_at").
err := db.Select("irl.id, irl.order_id, irl.order_no, irl.source, irl.distribution_site_id, irl.inviter_id, inviter.phone as inviter_phone, irl.invitee_id, invitee.phone as invitee_phone, irl.trade_role, irl.order_profit_basis_cent, irl.single_side_basis_cent, irl.rate_applied, irl.days_since_verified, irl.cycle_stage, irl.rebate_amount_cent, irl.status, irl.settled_at").
Order("irl.id DESC").Offset(offset).Limit(query.PageSize).Scan(&rows).Error
if err != nil {
return nil, 0, err
@@ -865,6 +991,7 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba
"id": row.ID,
"order_id": row.OrderID,
"order_no": row.OrderNo,
"source": row.Source,
"distribution_site_id": row.DistributionSiteID,
"inviter_id": row.InviterID,
"inviter_phone": row.InviterPhone,
@@ -289,3 +289,141 @@ func TestCrossSiteDistributionRebate(t *testing.T) {
t.Fatalf("卖家端跨站返利错误: siteID=%d, amount=%d", sellerLedger.DistributionSiteID, sellerLedger.RebateAmountCent)
}
}
// cycleLabel 的比例必须来自实际生效配置,改配置后文案要跟着变,
// 否则钱包流水备注会与后台比例不一致(历史缺陷)。
func TestCycleLabelFollowsConfiguredRate(t *testing.T) {
cases := []struct {
cycle string
rate float64
want string
}{
{CycleFirstMonth, 0.60, "首月奖励 60%"},
{CycleFirstMonth, 0.50, "首月奖励 50%"},
{CycleFirstMonth, 0.80, "首月奖励 80%"},
{CycleSecondMonth, 0.30, "次月奖励 30%"},
{CycleSecondMonth, 0.25, "次月奖励 25%"},
{CycleSubsequent, 0.15, "长期奖励 15%"},
{CycleSubsequent, 0.125, "长期奖励 12.5%"},
{CycleSubsequent, 0, "长期奖励 0%"},
}
for _, c := range cases {
if got := cycleLabel(c.cycle, c.rate); got != c.want {
t.Fatalf("cycleLabel(%s, %v) = %q, want %q", c.cycle, c.rate, got, c.want)
}
}
}
// 线下提号返利与租赁订单卖家侧共用同一个入口 AwardSellerSideRebateTx:
// 这里验证金额计算正确,且同一来源单据重复调用不会重复入账
// (唯一键 source+order_id+trade_role 保证幂等,补发脚本重跑安全)。
func TestAwardSellerSideRebateTxAwardsOncePerSource(t *testing.T) {
db := setupTestDB(t)
inviterCode := "inv9001"
inviter := model.User{
Phone: "13800009001",
Nickname: "提号邀请人",
RealnameStatus: "verified",
Status: "active",
InviteCode: &inviterCode,
}
if err := db.Create(&inviter).Error; err != nil {
t.Fatalf("创建邀请人失败: %v", err)
}
seller := model.User{
Phone: "13800009002",
Nickname: "提号号主",
RealnameStatus: "verified",
Status: "active",
}
if err := db.Create(&seller).Error; err != nil {
t.Fatalf("创建号主失败: %v", err)
}
verifiedAt := time.Now().AddDate(0, 0, -2) // 距结算 2 天 => 首月 60%
if err := db.Create(&model.UserInvitation{
InviterID: inviter.ID,
InviteeID: seller.ID,
InviteCode: inviterCode,
Status: StatusActive,
VerifiedAt: &verifiedAt,
}).Error; err != nil {
t.Fatalf("创建邀请关系失败: %v", err)
}
input := SellerSideRebateInput{
Source: RebateSourcePickup,
SourceID: 900001,
SourceNo: "PK-REBATE-TEST-1",
SellerID: seller.ID,
GrossBasisCent: 10000,
BasisCent: 5000,
SiteID: 0,
SettledAt: time.Now(),
RemarkPrefix: "邀请卖家提号返利",
}
apply := func() int64 {
t.Helper()
var amount int64
if err := db.Transaction(func(tx *gorm.DB) error {
got, err := AwardSellerSideRebateTx(tx, input)
amount = got
return err
}); err != nil {
t.Fatalf("发放提号返利失败: %v", err)
}
return amount
}
// 单边基数 5000,首月 60% => 3000 分
if got := apply(); got != 3000 {
t.Fatalf("首次发放金额 = %d, want 3000", got)
}
var rebate model.InvitationRebateLedger
if err := db.Where("source = ? AND order_id = ? AND trade_role = 'seller'", RebateSourcePickup, input.SourceID).
First(&rebate).Error; err != nil {
t.Fatalf("未找到提号返利流水: %v", err)
}
if rebate.InviterID != inviter.ID || rebate.InviteeID != seller.ID || rebate.OrderNo != input.SourceNo {
t.Fatalf("返利流水字段错误: %+v", rebate)
}
if rebate.CycleStage != CycleFirstMonth || rebate.DaysSinceVerified != 2 {
t.Fatalf("返利档位错误: cycle=%s, days=%d", rebate.CycleStage, rebate.DaysSinceVerified)
}
// 同来源同角色重复调用必须跳过,不重复写流水、不重复入账
if got := apply(); got != 0 {
t.Fatalf("重复调用应幂等跳过, got %d", got)
}
var ledgerCount int64
if err := db.Model(&model.InvitationRebateLedger{}).
Where("source = ? AND order_id = ?", RebateSourcePickup, input.SourceID).
Count(&ledgerCount).Error; err != nil {
t.Fatalf("统计返利流水失败: %v", err)
}
if ledgerCount != 1 {
t.Fatalf("返利流水条数 = %d, want 1", ledgerCount)
}
var walletCount int64
if err := db.Model(&model.WalletLedger{}).
Where("biz_type = ? AND biz_no = ?", "invite_rebate", input.SourceNo).
Count(&walletCount).Error; err != nil {
t.Fatalf("统计钱包流水失败: %v", err)
}
if walletCount != 1 {
t.Fatalf("钱包流水条数 = %d, want 1", walletCount)
}
var account model.WalletAccount
if err := db.Where("user_id = ? AND distribution_site_id = 0", inviter.ID).First(&account).Error; err != nil {
t.Fatalf("未找到邀请人钱包账户: %v", err)
}
if account.AvailableBalanceCent != 3000 {
t.Fatalf("邀请人可用余额 = %d, want 3000", account.AvailableBalanceCent)
}
}
@@ -14,6 +14,7 @@ import (
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/listingstatus"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/invitation"
"hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/modules/wallet"
"hfb_sys/backend/internal/timeutil"
@@ -305,6 +306,30 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete
return err
}
}
// 邀请返利:线下提号不走正常订单结账流程,必须在这里补发号主(卖家)侧返利,
// 否则号主邀请人永远拿不到提号产生的分成。基数按提号利润对半(与租赁订单卖家侧口径一致)。
if grossBasisCent := pickup.ProfitAmountCent; grossBasisCent > 0 {
sellerSiteID := uint64(0)
if listing.DistributionSiteID != nil {
sellerSiteID = *listing.DistributionSiteID
}
if _, err := invitation.AwardSellerSideRebateTx(tx, invitation.SellerSideRebateInput{
Source: invitation.RebateSourcePickup,
SourceID: pickup.ID,
SourceNo: pickup.PickupNo,
SellerID: pickup.OwnerID,
GrossBasisCent: grossBasisCent,
BasisCent: grossBasisCent / 2,
SiteID: sellerSiteID,
RateReferenceAt: now,
SettledAt: now,
RemarkPrefix: "邀请卖家提号返利",
}); err != nil {
return err
}
}
if err := appendPickupEvent(tx, pickup, "pickup_completed", adminID, "管理员完成提号。", pickup.CompleteRemark, map[string]any{
"settle_amount_cent": pickup.SettleAmountCent, "profit_amount_cent": pickup.ProfitAmountCent,
"settlement_mode": pickup.SettlementMode, "offline_settlement_status": pickup.OfflineSettlementStatus,
@@ -365,6 +365,74 @@ func TestRepositoryCompleteInternalPickupCreditsWallet(t *testing.T) {
assertInt64(t, "站内提号钱包入账", ledger.AmountCent, 12000)
}
// 线下提号不走正常订单结账流程,完成时必须同步给号主邀请人发放返利,
// 否则邀请人永远拿不到提号产生的分成(历史漏发缺口)。
func TestRepositoryCompletePickupAwardsSellerInvitationRebate(t *testing.T) {
repo, db := newPickupTestRepo(t)
inviter := model.User{Phone: "19900000001", Nickname: "提号邀请人", RealnameStatus: "verified", Status: "active"}
if err := db.Create(&inviter).Error; err != nil {
t.Fatalf("创建邀请人失败: %v", err)
}
listing := seedAvailableListing(t, db, "LST-REBATE-001", "返利提号账号", false)
verifiedAt := time.Now().AddDate(0, 0, -2) // 距提号完成 2 天 => 首月 60% 档
if err := db.Create(&model.UserInvitation{
InviterID: inviter.ID,
InviteeID: listing.OwnerID,
InviteCode: "rebate01",
Status: "active",
VerifiedAt: &verifiedAt,
}).Error; err != nil {
t.Fatalf("绑定号主邀请关系失败: %v", err)
}
pickup, err := repo.Create(t.Context(), CreateRequest{
ListingID: listing.ID,
OrderAmountCent: 20000,
ProfitAmountCent: 10000,
}, 99, auditlog.Meta{})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
var stored model.AdminPickup
if err := db.First(&stored, pickup.ID).Error; err != nil {
t.Fatalf("读取提号记录失败: %v", err)
}
if stored.ProfitAmountCent != 10000 {
t.Fatalf("提号利润被改写为 %d,期望 10000(用例前提失效,请同步调整断言)", stored.ProfitAmountCent)
}
if _, err := repo.Complete(t.Context(), pickup.ID, CompleteRequest{SettleAmountCent: 10000}, 99, auditlog.Meta{}); err != nil {
t.Fatalf("Complete() error = %v", err)
}
var rebate model.InvitationRebateLedger
if err := db.Where("source = ? AND order_id = ? AND trade_role = ?", "pickup", pickup.ID, "seller").
First(&rebate).Error; err != nil {
t.Fatalf("提号完成应发放号主邀请人返利: %v", err)
}
if rebate.InviterID != inviter.ID || rebate.InviteeID != listing.OwnerID {
t.Fatalf("返利双方 = %d/%d, want %d/%d", rebate.InviterID, rebate.InviteeID, inviter.ID, listing.OwnerID)
}
if rebate.OrderNo != pickup.PickupNo {
t.Fatalf("返利单据号 = %q, want %q", rebate.OrderNo, pickup.PickupNo)
}
// 基数 = 提号利润 10000 对半 = 5000,首月 60% = 3000
assertInt64(t, "提号返利单边基数", rebate.SingleSideBasisCent, 5000)
assertInt64(t, "提号返利金额", rebate.RebateAmountCent, 3000)
var wallet model.WalletLedger
if err := db.Where("biz_type = ? AND biz_no = ?", "invite_rebate", pickup.PickupNo).First(&wallet).Error; err != nil {
t.Fatalf("提号返利应写钱包流水: %v", err)
}
if wallet.UserID != inviter.ID {
t.Fatalf("返利入账用户 = %d, want %d", wallet.UserID, inviter.ID)
}
assertInt64(t, "提号返利入账金额", wallet.AmountCent, 3000)
}
func TestRepositoryCompletePlatformManagedPickupUsesOfflineSettlement(t *testing.T) {
repo, db := newPickupTestRepo(t)
listing := seedAvailableListing(t, db, "LST-CMPL-EXTERNAL", "代管结算账号", true)