From ead58417e01da5c523466ac937c47426db023cf4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 13 Oct 2026 15:12:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(invitation):=20=E7=BA=BF=E4=B8=8B=E6=8F=90?= =?UTF-8?q?=E5=8F=B7=E6=8E=A5=E5=85=A5=E9=82=80=E8=AF=B7=E8=BF=94=E5=88=A9?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E5=8F=91=E5=8E=86=E5=8F=B2=E6=BC=8F=E5=8F=91?= =?UTF-8?q?=EF=BC=8C=E8=BF=94=E5=88=A9=E6=AF=94=E4=BE=8B=E6=96=87=E6=A1=88?= =?UTF-8?q?=E6=94=B9=E8=AF=BB=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 线下提号(admin_pickups)不走订单结账流程,此前完全不发邀请返利;现于 Complete() 内按号主利润对半基数发放卖家侧返利 - 迁移 000086:invitation_rebate_ledgers 增加 source 列,唯一键调整为 (source, order_id, trade_role),支持提号来源且保持幂等 - 新增 cmd/rebate-backfill 幂等补发脚本:默认预演,加 -apply 才写库 - 返利阶段文案不再写死 60%/30%/15%,改为按本次实际生效比例生成(钱包流水备注同步修正) - 前台邀请页阶段标签/横幅/空态文案、移动端个人中心入口文案均改读接口配置,并修掉合法 0% 比例被 || 覆盖成 60% 的问题 - 新增单测:提号返利发放与幂等、cycleLabel 跟随后台配置 --- backend/cmd/rebate-backfill/main.go | 183 ++++++++++++++++++ backend/internal/model/invitation.go | 5 +- .../internal/modules/invitation/repository.go | 141 +++++++++++++- .../modules/invitation/repository_test.go | 138 +++++++++++++ backend/internal/modules/pickup/repository.go | 25 +++ .../modules/pickup/repository_test.go | 68 +++++++ .../000086_invitation_rebate_source.sql | 20 ++ .../features/admin/api/adminInvitations.ts | 2 + .../components/AdminUserDetailDrawer.vue | 20 +- .../admin/views/AdminInvitationsView.vue | 13 +- .../features/auth/views/MobileProfileView.vue | 18 +- .../invitation/views/UserInvitationView.vue | 47 ++++- 12 files changed, 658 insertions(+), 22 deletions(-) create mode 100644 backend/cmd/rebate-backfill/main.go create mode 100644 backend/migrations/000086_invitation_rebate_source.sql diff --git a/backend/cmd/rebate-backfill/main.go b/backend/cmd/rebate-backfill/main.go new file mode 100644 index 0000000..04219c3 --- /dev/null +++ b/backend/cmd/rebate-backfill/main.go @@ -0,0 +1,183 @@ +// rebate-backfill 为「线下提号」补发邀请返利(一次性运维脚本,可安全重复执行)。 +// +// 背景:线下提号(admin_pickups)不走正常订单结账流程,历史上未接入邀请返利结算, +// 导致已完成提号单的号主邀请人拿不到分成。代码侧已在 pickup.Complete 内接入, +// 本脚本用于补齐「接入前已完成」的历史提号单;仍在进行中的提号单会在完成时自动发放。 +// +// 幂等:写入 (source='pickup', order_id, trade_role='seller') 唯一键, +// 且候选集已排除已有提号返利记录的单据,重复执行不会重复发放。 +// +// 口径:单边基数 = 提号利润对半(profit_amount_cent / 2),与租赁订单卖家侧一致; +// 比例阶梯按提号完成时刻距今(被邀请人实名生效)的档位计算。 +// +// 用法(默认预演,不写库): +// +// go run ./cmd/rebate-backfill # 预演,只列待补发单据 +// go run ./cmd/rebate-backfill -apply # 实际补发 +// go run ./cmd/rebate-backfill -apply -pickup-no PK20261011113459355 +package main + +import ( + "flag" + "fmt" + "log" + "strings" + "time" + + "hfb_sys/backend/internal/config" + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/logging" + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/invitation" + "hfb_sys/backend/internal/modules/pickup" + "hfb_sys/backend/pkg/money" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type candidate struct { + ID uint64 + PickupNo string + OwnerID uint64 + ProfitAmountCent int64 + CompletedAt *time.Time + ListingSiteID *uint64 +} + +func main() { + apply := flag.Bool("apply", false, "实际执行补发(默认仅预演)") + pickupNo := flag.String("pickup-no", "", "仅处理指定提号单号") + limit := flag.Int("limit", 0, "最多处理条数,0 表示不限制") + flag.Parse() + + cfg := config.Load() + logger, err := logging.New(cfg.Log) + if err != nil { + log.Fatalf("初始化日志失败: %v", err) + } + defer func() { + _ = logger.Sync() + }() + + db, err := database.OpenMySQLWithOptions(cfg.MySQLDSN, cfg.Log.Level, logger, database.MySQLOptions{}) + if err != nil { + log.Fatalf("连接数据库失败: %v", err) + } + + rows, err := loadCandidates(db, *pickupNo, *limit) + if err != nil { + log.Fatalf("查询待补发提号单失败: %v", err) + } + if len(rows) == 0 { + fmt.Println("没有需要补发的提号单:已完成提号单均已覆盖或不存在生效邀请关系。") + return + } + + mode := "预演" + if *apply { + mode = "实际补发" + } + fmt.Printf("模式=%s,候选提号单=%d\n", mode, len(rows)) + + var paidCount, skippedCount int + var paidCent int64 + for _, row := range rows { + completedAt := time.Now() + if row.CompletedAt != nil { + completedAt = *row.CompletedAt + } + siteID := uint64(0) + if row.ListingSiteID != nil { + siteID = *row.ListingSiteID + } + + if !*apply { + fmt.Printf("[预演] %s 号主=%d 利润=%s 单边基数=%s 完成于=%s\n", + row.PickupNo, row.OwnerID, + money.FormatWithSymbol(row.ProfitAmountCent), + money.FormatWithSymbol(row.ProfitAmountCent/2), + completedAt.Format("2006-01-02 15:04:05")) + continue + } + + amountCent, err := backfillOne(db, row.ID, siteID, completedAt) + if err != nil { + log.Printf("补发失败 %s: %v", row.PickupNo, err) + continue + } + if amountCent <= 0 { + skippedCount++ + fmt.Printf("[跳过] %s 未产生返利(无生效邀请 / 邀请人未实名 / 返利已关闭 / 已存在记录)\n", row.PickupNo) + continue + } + paidCount++ + paidCent += amountCent + fmt.Printf("[补发] %s 返利 %s 已入邀请人钱包\n", row.PickupNo, money.FormatWithSymbol(amountCent)) + } + + if *apply { + fmt.Printf("完成:补发 %d 单,合计 %s;跳过 %d 单。\n", + paidCount, money.FormatWithSymbol(paidCent), skippedCount) + } +} + +func loadCandidates(db *gorm.DB, pickupNo string, limit int) ([]candidate, error) { + query := db.Table("admin_pickups AS p"). + Select(`p.id, p.pickup_no, p.owner_id, p.profit_amount_cent, p.completed_at, + l.distribution_site_id AS listing_site_id`). + Joins("JOIN user_invitations i ON i.invitee_id = p.owner_id AND i.status = ? AND i.inviter_id <> p.owner_id", invitation.StatusActive). + Joins("LEFT JOIN rental_listings l ON l.id = p.listing_id"). + Where("p.status = ?", pickup.StatusCompleted). + Where("p.profit_amount_cent > 0"). + Where(`NOT EXISTS ( + SELECT 1 FROM invitation_rebate_ledgers r + WHERE r.source = ? AND r.order_id = p.id AND r.trade_role = 'seller' + )`, invitation.RebateSourcePickup). + Order("p.completed_at ASC") + + if trimmed := strings.TrimSpace(pickupNo); trimmed != "" { + query = query.Where("p.pickup_no = ?", trimmed) + } + if limit > 0 { + query = query.Limit(limit) + } + + var rows []candidate + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// backfillOne 在独立事务内为单张提号单补发返利并返回实际发放金额(分)。 +func backfillOne(db *gorm.DB, pickupID uint64, siteID uint64, completedAt time.Time) (int64, error) { + var amountCent int64 + err := db.Transaction(func(tx *gorm.DB) error { + var record model.AdminPickup + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&record, pickupID).Error; err != nil { + return err + } + if record.Status != pickup.StatusCompleted { + return nil + } + amount, err := invitation.AwardSellerSideRebateTx(tx, invitation.SellerSideRebateInput{ + Source: invitation.RebateSourcePickup, + SourceID: record.ID, + SourceNo: record.PickupNo, + SellerID: record.OwnerID, + GrossBasisCent: record.ProfitAmountCent, + BasisCent: record.ProfitAmountCent / 2, + SiteID: siteID, + RateReferenceAt: completedAt, + SettledAt: time.Now(), + RemarkPrefix: "邀请卖家提号返利", + }) + if err != nil { + return err + } + amountCent = amount + return nil + }) + return amountCent, err +} diff --git a/backend/internal/model/invitation.go b/backend/internal/model/invitation.go index 03a62c6..0a27d8f 100644 --- a/backend/internal/model/invitation.go +++ b/backend/internal/model/invitation.go @@ -38,12 +38,13 @@ func (InvitationRebateConfig) TableName() string { // InvitationRebateLedger 邀请返利结算记录明细表 type InvitationRebateLedger struct { ID uint64 `gorm:"primaryKey" json:"id"` - OrderID uint64 `gorm:"not null;uniqueIndex:uk_invitation_rebate_order_role,priority:1" json:"order_id"` + OrderID uint64 `gorm:"not null;uniqueIndex:uk_invitation_rebate_source_role,priority:2" json:"order_id"` OrderNo string `gorm:"size:64;not null" json:"order_no"` + Source string `gorm:"size:32;not null;default:'rental_order';uniqueIndex:uk_invitation_rebate_source_role,priority:1" json:"source"` // rental_order / pickup DistributionSiteID uint64 `gorm:"not null;default:0;index:idx_invitation_rebate_site" json:"distribution_site_id"` InviterID uint64 `gorm:"not null;index:idx_invitation_rebate_inviter" json:"inviter_id"` InviteeID uint64 `gorm:"not null" json:"invitee_id"` - TradeRole string `gorm:"size:16;not null;uniqueIndex:uk_invitation_rebate_order_role,priority:2" json:"trade_role"` // buyer / seller + TradeRole string `gorm:"size:16;not null;uniqueIndex:uk_invitation_rebate_source_role,priority:3" json:"trade_role"` // buyer / seller OrderProfitBasisCent int64 `gorm:"not null;default:0" json:"order_profit_basis_cent"` SingleSideBasisCent int64 `gorm:"not null;default:0" json:"single_side_basis_cent"` RateApplied float64 `gorm:"type:decimal(6,4);not null;default:0" json:"rate_applied"` diff --git a/backend/internal/modules/invitation/repository.go b/backend/internal/modules/invitation/repository.go index 733dd93..acddbee 100644 --- a/backend/internal/modules/invitation/repository.go +++ b/backend/internal/modules/invitation/repository.go @@ -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, diff --git a/backend/internal/modules/invitation/repository_test.go b/backend/internal/modules/invitation/repository_test.go index 31a5191..12e070f 100644 --- a/backend/internal/modules/invitation/repository_test.go +++ b/backend/internal/modules/invitation/repository_test.go @@ -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) + } +} diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go index 0e05b53..2b1f07d 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -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, diff --git a/backend/internal/modules/pickup/repository_test.go b/backend/internal/modules/pickup/repository_test.go index 4a31239..86ea617 100644 --- a/backend/internal/modules/pickup/repository_test.go +++ b/backend/internal/modules/pickup/repository_test.go @@ -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) diff --git a/backend/migrations/000086_invitation_rebate_source.sql b/backend/migrations/000086_invitation_rebate_source.sql new file mode 100644 index 0000000..1269fc8 --- /dev/null +++ b/backend/migrations/000086_invitation_rebate_source.sql @@ -0,0 +1,20 @@ +-- +goose Up + +-- 邀请返利来源扩展:线下提号(admin_pickups)此前不经过正常订单结账流程, +-- 因此提号产生的平台毛利从未参与返利计算(已确认漏发)。 +-- source 标识来源单据类型:rental_order=租赁订单,pickup=线下提号; +-- order_id 统一存「来源单据 ID」(rental_orders.id 或 admin_pickups.id); +-- 唯一键加入 source 后,两类来源的幂等键互不干扰,历史租赁订单行默认 rental_order。 +ALTER TABLE invitation_rebate_ledgers + ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'rental_order' COMMENT '来源单据类型: rental_order=租赁订单, pickup=线下提号' AFTER order_no, + MODIFY COLUMN order_id BIGINT UNSIGNED NOT NULL COMMENT '来源单据ID(rental_order=>rental_orders.id;pickup=>admin_pickups.id)', + DROP INDEX uk_invitation_rebate_order_role, + ADD UNIQUE KEY uk_invitation_rebate_source_role (source, order_id, trade_role); + +-- +goose Down + +ALTER TABLE invitation_rebate_ledgers + DROP INDEX uk_invitation_rebate_source_role, + ADD UNIQUE KEY uk_invitation_rebate_order_role (order_id, trade_role), + MODIFY COLUMN order_id BIGINT UNSIGNED NOT NULL COMMENT '关联订单ID', + DROP COLUMN source; diff --git a/frontend/src/features/admin/api/adminInvitations.ts b/frontend/src/features/admin/api/adminInvitations.ts index e45a06d..b137c12 100644 --- a/frontend/src/features/admin/api/adminInvitations.ts +++ b/frontend/src/features/admin/api/adminInvitations.ts @@ -23,6 +23,8 @@ export interface AdminRebateRow { id: number order_id: number order_no: string + /** 来源单据类型:rental_order=租赁订单,pickup=线下提号 */ + source?: string distribution_site_id: number inviter_id: number inviter_phone: string diff --git a/frontend/src/features/admin/components/AdminUserDetailDrawer.vue b/frontend/src/features/admin/components/AdminUserDetailDrawer.vue index f10d684..86c6679 100644 --- a/frontend/src/features/admin/components/AdminUserDetailDrawer.vue +++ b/frontend/src/features/admin/components/AdminUserDetailDrawer.vue @@ -367,6 +367,13 @@ function cycleStageLabel(stage: string) { return labels[stage] || stage || '-' } +/** 返利来源单据类型文案(提号单不走正常订单流程,需要单独标识)。 */ +function rebateSourceLabel(source?: string) { + if (source === 'pickup') return '提号' + if (source === 'rental_order') return '订单' + return source || '订单' +} + function paymentStatusLabel(status: string) { const labels: Record = { created: '已创建', @@ -652,7 +659,18 @@ function goPayments() { - + + + +