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