diff --git a/backend/cmd/rebate-backfill-rental/main.go b/backend/cmd/rebate-backfill-rental/main.go new file mode 100644 index 0000000..af51b58 --- /dev/null +++ b/backend/cmd/rebate-backfill-rental/main.go @@ -0,0 +1,228 @@ +// rebate-backfill-rental 为「仲裁按实际消耗结算」的租赁订单补发邀请返利。 +// +// 背景:仲裁结案历史路径未触发邀请返利;且订单保留原预收金额快照, +// 不能直接用 order.rent_amount_cent - owner_rent_amount_cent 计算。 +// 本脚本按仲裁后的实际平台毛利(优先取已结算 checkout 的平台费, +// 否则取 actual_pure_coin_amount_cent - 仲裁入账给号主的金额)补发。 +// +// 幂等:复用 invitation_rebate_ledgers 的 (source, order_id, trade_role) 唯一键。 +// +// 用法(默认预演): +// +// go run ./cmd/rebate-backfill-rental -order-no RO20261017002241506,RO20261012185753383 +// go run ./cmd/rebate-backfill-rental -apply -order-no RO20261017002241506,RO20261012185753383 +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/pkg/money" + + "gorm.io/gorm" +) + +type candidate struct { + Order model.RentalOrder + ActualProfitCent int64 + ActualProfitSource string +} + +func main() { + apply := flag.Bool("apply", false, "实际执行补发(默认仅预演)") + orderNos := flag.String("order-no", "", "需要补发的租赁订单号,多个用逗号分隔") + flag.Parse() + + orderNoList := splitOrderNos(*orderNos) + if len(orderNoList) == 0 { + log.Fatal("必须通过 -order-no 指定订单号,避免误补全站历史订单") + } + + 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, orderNoList) + 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 int + var paidCent int64 + for _, row := range rows { + rateAt := time.Now() + if row.Order.SettledAt != nil { + rateAt = *row.Order.SettledAt + } + fmt.Printf("[%s] %s 号主=%d 原毛利=%s 实际平台毛利=%s(%s)计提时刻=%s\n", + mode, row.Order.OrderNo, row.Order.OwnerID, + money.FormatWithSymbol(row.Order.RentAmountCent-row.Order.OwnerRentAmountCent), + money.FormatWithSymbol(row.ActualProfitCent), row.ActualProfitSource, + rateAt.Format("2006-01-02 15:04:05")) + + if !*apply { + continue + } + amountCent, err := backfillOne(db, row, rateAt) + if err != nil { + log.Printf("补发失败 %s: %v", row.Order.OrderNo, err) + continue + } + if amountCent <= 0 { + fmt.Printf("[跳过] %s 未产生返利(无生效邀请 / 邀请人未实名 / 返利已关闭 / 已存在记录)\n", row.Order.OrderNo) + continue + } + paidCount++ + paidCent += amountCent + fmt.Printf("[补发] %s 返利 %s 已入邀请人钱包\n", row.Order.OrderNo, money.FormatWithSymbol(amountCent)) + } + + if *apply { + fmt.Printf("完成:补发 %d 单,合计 %s。\n", paidCount, money.FormatWithSymbol(paidCent)) + } +} + +func splitOrderNos(raw string) []string { + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if value := strings.TrimSpace(part); value != "" { + result = append(result, value) + } + } + return result +} + +func loadCandidates(db *gorm.DB, orderNos []string) ([]candidate, error) { + var orders []model.RentalOrder + if err := db.Where("order_no IN ?", orderNos).Order("id ASC").Find(&orders).Error; err != nil { + return nil, err + } + + result := make([]candidate, 0, len(orders)) + for _, order := range orders { + if order.Status != "completed" || order.SettlementStatus != "arbitrated" { + continue + } + var rebateCount int64 + if err := db.Model(&model.InvitationRebateLedger{}). + Where("source = ? AND order_id = ? AND trade_role = 'seller'", invitation.RebateSourceRentalOrder, order.ID). + Count(&rebateCount).Error; err != nil { + return nil, err + } + if rebateCount > 0 { + continue + } + + profitCent, source, err := actualProfitCent(db, order) + if err != nil { + return nil, err + } + if profitCent <= 0 { + continue + } + result = append(result, candidate{ + Order: order, + ActualProfitCent: profitCent, + ActualProfitSource: source, + }) + } + return result, nil +} + +// actualProfitCent 返回仲裁后的实际平台毛利。 +// 已结算 checkout 的平台费最准确;没有 checkout 时用实际纯币收入减去仲裁入账号主的金额。 +func actualProfitCent(db *gorm.DB, order model.RentalOrder) (int64, string, error) { + var checkout model.OrderCheckout + checkoutErr := db.Where("order_id = ? AND status = ?", order.ID, "settled"). + Order("id DESC").First(&checkout).Error + if checkoutErr == nil && checkout.PlatformFeeCent > 0 { + return checkout.PlatformFeeCent, "settled_checkout.platform_fee_cent", nil + } + if checkoutErr != nil && checkoutErr != gorm.ErrRecordNotFound { + return 0, "", checkoutErr + } + + var ownerIncomeCent int64 + if err := db.Model(&model.WalletLedger{}). + Where("order_id = ? AND biz_type = ? AND direction = 'in' AND balance_type = 'available'", order.ID, "arbitration_owner_income"). + Select("COALESCE(SUM(amount_cent), 0)").Scan(&ownerIncomeCent).Error; err != nil { + return 0, "", err + } + if order.ActualPureCoinAmountCent <= 0 || ownerIncomeCent <= 0 { + return 0, "", nil + } + return order.ActualPureCoinAmountCent - ownerIncomeCent, "actual_pure_coin - arbitration_owner_income", nil +} + +func backfillOne(db *gorm.DB, row candidate, rateAt time.Time) (int64, error) { + var amountCent int64 + err := db.Transaction(func(tx *gorm.DB) error { + // 事务内重新读取,避免预演后订单状态或金额发生变化。 + var order model.RentalOrder + if err := tx.First(&order, row.Order.ID).Error; err != nil { + return err + } + if order.Status != "completed" || order.SettlementStatus != "arbitrated" { + return nil + } + + profitCent, _, err := actualProfitCent(tx, order) + if err != nil { + return err + } + if profitCent <= 0 { + return nil + } + + options := invitation.OrderRebateOptions{ + GrossProfitCent: &profitCent, + RateReferenceAt: rateAt, + SettledAt: time.Now(), + } + before := int64(0) + _ = tx.Model(&model.InvitationRebateLedger{}). + Where("source = ? AND order_id = ? AND trade_role = 'seller'", invitation.RebateSourceRentalOrder, order.ID). + Select("COALESCE(SUM(rebate_amount_cent), 0)").Scan(&before).Error + if err := invitation.ProcessOrderInvitationRebateWithOptionsTx(tx, order.ID, options); err != nil { + return err + } + var after int64 + if err := tx.Model(&model.InvitationRebateLedger{}). + Where("source = ? AND order_id = ? AND trade_role = 'seller'", invitation.RebateSourceRentalOrder, order.ID). + Select("COALESCE(SUM(rebate_amount_cent), 0)").Scan(&after).Error; err != nil { + return err + } + amountCent = after - before + return nil + }) + return amountCent, err +} diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index 2c954d8..10a250b 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -12,6 +12,7 @@ import ( "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/adminnotification" "hfb_sys/backend/internal/modules/distribution" + "hfb_sys/backend/internal/modules/invitation" "hfb_sys/backend/internal/modules/notification" ordermodule "hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/modules/rentergrowth" @@ -134,6 +135,24 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r if err := tx.Save(&order).Error; err != nil { return err } + if order.Status == "completed" { + if actualSettlement == nil { + return ErrInvalidDispute + } + // 仲裁按实际消耗结算后,订单仍保留原预收金额快照;返利必须使用实际平台毛利。 + grossProfitCent := actualSettlement.PlatformFeeCent + if err := invitation.ProcessOrderInvitationRebateWithOptionsTx(tx, order.ID, invitation.OrderRebateOptions{ + GrossProfitCent: &grossProfitCent, + RateReferenceAt: now, + }); err != nil { + logging.FromContext(ctx).Error("仲裁结算邀请返利失败", + zap.String("module", "dispute"), + zap.Uint64("order_id", order.ID), + zap.Int64("actual_platform_fee_cent", grossProfitCent), + zap.Error(err), + ) + } + } if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 { if err := distribution.ApplyRefundTx(tx, order.ID, refundAmountCent); err != nil { return err diff --git a/backend/internal/modules/dispute/repository_test.go b/backend/internal/modules/dispute/repository_test.go index 2eb4f2c..f5c5bc0 100644 --- a/backend/internal/modules/dispute/repository_test.go +++ b/backend/internal/modules/dispute/repository_test.go @@ -3,12 +3,14 @@ package dispute import ( "context" "errors" + "math" "strings" "testing" "time" "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" + ordermodule "hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/modules/wallet" "gorm.io/datatypes" @@ -536,6 +538,61 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) { } } +func TestArbitrationActualSettlementAwardsSellerRebateOnActualProfit(t *testing.T) { + db := setupDisputeTestDB(t) + repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { + return "refunded", nil + })}) + owner, renter, order := createDisputeOrderFixture(t, db, disputePureCoinOrder()) + + inviter := model.User{Phone: "13800000003", RealnameStatus: "verified", Status: "active"} + if err := db.Create(&inviter).Error; err != nil { + t.Fatalf("创建邀请人失败: %v", err) + } + verifiedAt := time.Now().AddDate(0, 0, -1).Truncate(time.Second) + if err := db.Create(&model.UserInvitation{ + InviterID: inviter.ID, InviteeID: owner.ID, Status: "active", VerifiedAt: &verifiedAt, + }).Error; err != nil { + t.Fatalf("创建邀请关系失败: %v", err) + } + + created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{ + Type: "haf_coin_dispute", + Description: "按实际消耗仲裁", + }) + if err != nil { + t.Fatalf("创建申诉失败: %v", err) + } + actualCoinConsumedM := 50.0 + actualConsumableAmountCent := int64(12000) + if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{ + Result: "actual_settlement", + Remark: "按实际消耗结算", + ActualCoinConsumedM: &actualCoinConsumedM, + ActualConsumableAmountCent: &actualConsumableAmountCent, + }, AuditMeta{}); err != nil { + t.Fatalf("仲裁失败: %v", err) + } + + var saved model.RentalOrder + if err := db.First(&saved, order.ID).Error; err != nil { + t.Fatalf("读取订单失败: %v", err) + } + actual := ordermodule.CalculateActualCheckoutSettlement(saved, actualConsumableAmountCent, actualCoinConsumedM, 0) + expectedBasis := actual.PlatformFeeCent / 2 + expectedRebate := int64(math.Round(float64(expectedBasis) * 0.6)) + + var ledger model.InvitationRebateLedger + if err := db.Where("order_id = ? AND trade_role = 'seller'", order.ID).First(&ledger).Error; err != nil { + t.Fatalf("查询仲裁返利流水失败: %v", err) + } + if ledger.OrderProfitBasisCent != actual.PlatformFeeCent || ledger.SingleSideBasisCent != expectedBasis || ledger.RebateAmountCent != expectedRebate { + t.Fatalf("仲裁返利基数/单边/金额 = %d/%d/%d, want %d/%d/%d", + ledger.OrderProfitBasisCent, ledger.SingleSideBasisCent, ledger.RebateAmountCent, + actual.PlatformFeeCent, expectedBasis, expectedRebate) + } +} + func TestBuildArbitrationSettlementSkipsFrozenReleaseWhenNoFrozenBalance(t *testing.T) { order := model.RentalOrder{ ID: 11, diff --git a/backend/internal/modules/invitation/repository.go b/backend/internal/modules/invitation/repository.go index d320c5f..6b01d6e 100644 --- a/backend/internal/modules/invitation/repository.go +++ b/backend/internal/modules/invitation/repository.go @@ -344,8 +344,20 @@ func CalculateRebateRate(verifiedAt time.Time, now time.Time, cfg *model.Invitat return subsequentRate, CycleSubsequent, days } +// OrderRebateOptions 允许调用方覆盖订单毛利基数与返利计算时刻。 +// 仲裁按实际消耗结算后,订单快照金额仍是原预收金额,必须传入实际平台毛利。 +type OrderRebateOptions struct { + GrossProfitCent *int64 + RateReferenceAt time.Time + SettledAt time.Time +} + // ProcessOrderInvitationRebate 在订单终态结算时计算并分发买卖双边邀请返利。 func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.DB, orderID uint64) error { + return r.processOrderInvitationRebate(ctx, tx, orderID, OrderRebateOptions{}) +} + +func (r *Repository) processOrderInvitationRebate(ctx context.Context, tx *gorm.DB, orderID uint64, opts OrderRebateOptions) error { var order model.RentalOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { return err @@ -355,6 +367,9 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm. } orderGrossProfitCent := order.RentAmountCent - order.OwnerRentAmountCent + if opts.GrossProfitCent != nil { + orderGrossProfitCent = *opts.GrossProfitCent + } if orderGrossProfitCent <= 0 { return nil } @@ -385,6 +400,14 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm. } now := time.Now() + rateReferenceAt := now + if !opts.RateReferenceAt.IsZero() { + rateReferenceAt = opts.RateReferenceAt + } + settledAt := now + if !opts.SettledAt.IsZero() { + settledAt = opts.SettledAt + } // 返利归因站点(统一规则):成本一律由「订单所属站点」承担,并使用该站点自己的返利配置。 // 订单未记录站点但存在分销拆分时,回退到拆分方站点(钱实际归它), @@ -411,7 +434,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm. if buyerBasisCent > 0 { cfg, _ := r.GetRebateConfig(ctx, orderSiteID) if cfg == nil || cfg.Enabled { - rate, cycle, days := CalculateRebateRate(*buyerInvitation.VerifiedAt, now, cfg) + rate, cycle, days := CalculateRebateRate(*buyerInvitation.VerifiedAt, rateReferenceAt, cfg) rebateCent := int64(math.Round(float64(buyerBasisCent) * rate)) if rebateCent > 0 { ledgerNo := fmt.Sprintf("REB-B-%s-%d", order.OrderNo, buyerInvitation.InviterID) @@ -430,7 +453,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm. RebateAmountCent: rebateCent, WalletLedgerNo: ledgerNo, Status: "settled", - SettledAt: now, + SettledAt: settledAt, } if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ledger).Error; err == nil && ledger.ID > 0 { _ = wallet.AppendEntries(tx, wallet.Entry{ @@ -468,7 +491,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm. if sellerBasisCent > 0 { cfg, _ := r.GetRebateConfig(ctx, orderSiteID) if cfg == nil || cfg.Enabled { - rate, cycle, days := CalculateRebateRate(*sellerInvitation.VerifiedAt, now, cfg) + rate, cycle, days := CalculateRebateRate(*sellerInvitation.VerifiedAt, rateReferenceAt, cfg) rebateCent := int64(math.Round(float64(sellerBasisCent) * rate)) if rebateCent > 0 { ledgerNo := fmt.Sprintf("REB-S-%s-%d", order.OrderNo, sellerInvitation.InviterID) @@ -487,7 +510,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm. RebateAmountCent: rebateCent, WalletLedgerNo: ledgerNo, Status: "settled", - SettledAt: now, + SettledAt: settledAt, } if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ledger).Error; err == nil && ledger.ID > 0 { _ = wallet.AppendEntries(tx, wallet.Entry{ @@ -1018,11 +1041,16 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba // ProcessOrderInvitationRebateTx 便捷事务入口,供订单结账完成终态时调用 func ProcessOrderInvitationRebateTx(tx *gorm.DB, orderID uint64) error { + return ProcessOrderInvitationRebateWithOptionsTx(tx, orderID, OrderRebateOptions{}) +} + +// ProcessOrderInvitationRebateWithOptionsTx 支持覆盖实际结算基数与计算时刻。 +func ProcessOrderInvitationRebateWithOptionsTx(tx *gorm.DB, orderID uint64, opts OrderRebateOptions) error { if tx == nil || orderID == 0 { return nil } repo := NewRepository(tx) - return repo.ProcessOrderInvitationRebate(context.Background(), tx, orderID) + return repo.processOrderInvitationRebate(context.Background(), tx, orderID, opts) } // OnUserRealnameVerifiedTx 便捷事务入口,供实名认证通过时调用 diff --git a/backend/internal/modules/invitation/repository_test.go b/backend/internal/modules/invitation/repository_test.go index c062da0..1108274 100644 --- a/backend/internal/modules/invitation/repository_test.go +++ b/backend/internal/modules/invitation/repository_test.go @@ -192,6 +192,76 @@ func TestInvitationFlowAndOrderRebate(t *testing.T) { } } +func TestProcessOrderInvitationRebateUsesActualProfitBasis(t *testing.T) { + db := setupTestDB(t) + + inviter := model.User{Phone: "13800000011", RealnameStatus: "verified", Status: "active"} + if err := db.Create(&inviter).Error; err != nil { + t.Fatalf("创建邀请人失败: %v", err) + } + seller := model.User{Phone: "13800000012", RealnameStatus: "verified", Status: "active"} + if err := db.Create(&seller).Error; err != nil { + t.Fatalf("创建卖家失败: %v", err) + } + verifiedAt := time.Now().AddDate(0, 0, -2).Truncate(time.Second) + if err := db.Create(&model.UserInvitation{ + InviterID: inviter.ID, InviteeID: seller.ID, Status: StatusActive, VerifiedAt: &verifiedAt, + }).Error; err != nil { + t.Fatalf("创建邀请关系失败: %v", err) + } + + order := model.RentalOrder{ + OrderNo: fmt.Sprintf("ORD-ACTUAL-%d", time.Now().UnixNano()), + OwnerID: seller.ID, + RenterID: 99999, + Status: "completed", + SettlementStatus: "arbitrated", + RentAmountCent: 10670, + OwnerRentAmountCent: 7270, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("创建订单失败: %v", err) + } + + actualPlatformFeeCent := int64(933) + if err := ProcessOrderInvitationRebateWithOptionsTx(db, order.ID, OrderRebateOptions{ + GrossProfitCent: &actualPlatformFeeCent, + RateReferenceAt: time.Now(), + }); err != nil { + t.Fatalf("按实际毛利处理返利失败: %v", err) + } + + var ledger model.InvitationRebateLedger + if err := db.Where("order_id = ? AND trade_role = 'seller'", order.ID).First(&ledger).Error; err != nil { + t.Fatalf("查询返利流水失败: %v", err) + } + if ledger.OrderProfitBasisCent != 933 || ledger.SingleSideBasisCent != 466 || ledger.RebateAmountCent != 280 { + t.Fatalf("返利基数/单边/金额 = %d/%d/%d, want 933/466/280", + ledger.OrderProfitBasisCent, ledger.SingleSideBasisCent, ledger.RebateAmountCent) + } + + 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 != 280 { + t.Fatalf("邀请人余额 = %d, want 280", account.AvailableBalanceCent) + } + + if err := ProcessOrderInvitationRebateWithOptionsTx(db, order.ID, OrderRebateOptions{ + GrossProfitCent: &actualPlatformFeeCent, + RateReferenceAt: time.Now(), + }); err != nil { + t.Fatalf("重复调用返利失败: %v", err) + } + var ledgerCount, walletCount int64 + db.Model(&model.InvitationRebateLedger{}).Where("order_id = ?", order.ID).Count(&ledgerCount) + db.Model(&model.WalletLedger{}).Where("biz_no = ? AND biz_type = 'invite_rebate'", order.OrderNo).Count(&walletCount) + if ledgerCount != 1 || walletCount != 1 { + t.Fatalf("重复调用产生重复流水: rebate=%d wallet=%d", ledgerCount, walletCount) + } +} + func TestCrossSiteDistributionRebate(t *testing.T) { db := setupTestDB(t) repo := NewRepository(db)