// pickup-points-backfill 为「线下提号」补发号主成长积分。 // // 背景:优质号主功能上线前,线下提号完成只做钱包结算和邀请返利,没有发放成长积分。 // 本脚本只补发指定时间之后、真实号主(排除 admin: 内部账号)的已完成提号单。 // // 幂等:积分流水 biz_key = pickup::owner:pickup_completed, // 候选集已排除已有积分流水的提号单,重复执行不会重复加分。 // // 用法(默认预演,不写库): // // go run ./cmd/pickup-points-backfill // go run ./cmd/pickup-points-backfill -apply // go run ./cmd/pickup-points-backfill -apply -pickup-no PK202611050001 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/pickup" "hfb_sys/backend/internal/modules/rentergrowth" "hfb_sys/backend/internal/timeutil" "hfb_sys/backend/pkg/money" "gorm.io/gorm" "gorm.io/gorm/clause" ) const defaultSince = "2026-11-04 21:02:24" type candidate struct { ID uint64 PickupNo string OwnerID uint64 OwnerPhone string SettleAmountCent int64 CompletedAt *time.Time } func main() { apply := flag.Bool("apply", false, "实际执行补发(默认仅预演)") sinceText := flag.String("since", defaultSince, "只处理该时间之后完成的提号单,格式 YYYY-MM-DD HH:MM:SS") pickupNo := flag.String("pickup-no", "", "仅处理指定提号单号") limit := flag.Int("limit", 0, "最多处理条数,0 表示不限制") includeInternal := flag.Bool("include-internal", false, "是否包含 admin: 内部账号") flag.Parse() since, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(*sinceText), timeutil.ShanghaiLocation()) if err != nil { log.Fatalf("解析 -since 失败: %v", err) } 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, since, *pickupNo, *limit, *includeInternal) if err != nil { log.Fatalf("查询待补发提号单失败: %v", err) } if len(rows) == 0 { fmt.Println("没有需要补发积分的提号单。") return } mode := "预演" if *apply { mode = "实际补发" } ownerIDs := make(map[uint64]struct{}, len(rows)) var basisCent int64 for _, row := range rows { ownerIDs[row.OwnerID] = struct{}{} basisCent += row.SettleAmountCent } fmt.Printf("模式=%s,起点=%s,候选提号单=%d,号主=%d,结算基数=%s\n", mode, since.Format("2006-01-02 15:04:05"), len(rows), len(ownerIDs), money.FormatWithSymbol(basisCent)) var awardedCount, skippedCount int var awardedPoints int64 awardedOwnerIDs := make(map[uint64]struct{}, len(rows)) for _, row := range rows { if !*apply { fmt.Printf("[预演] %s 号主=%d 积分基数=%s\n", row.PickupNo, row.OwnerID, money.FormatWithSymbol(row.SettleAmountCent)) continue } points, err := backfillOne(db, row.ID) if err != nil { log.Printf("补发失败 %s: %v", row.PickupNo, err) continue } if points <= 0 { skippedCount++ fmt.Printf("[跳过] %s 未产生积分(积分功能关闭 / 已存在流水 / 金额为 0)\n", row.PickupNo) continue } awardedCount++ awardedOwnerIDs[row.OwnerID] = struct{}{} awardedPoints += points fmt.Printf("[补发] %s 号主=%d 积分=+%d\n", row.PickupNo, row.OwnerID, points) } if *apply { fmt.Printf("完成:补发 %d 单 / %d 人,积分 +%d,跳过 %d 单。\n", awardedCount, len(awardedOwnerIDs), awardedPoints, skippedCount) } } func loadCandidates(db *gorm.DB, since time.Time, pickupNo string, limit int, includeInternal bool) ([]candidate, error) { query := db.Table("admin_pickups AS p"). Select("p.id, p.pickup_no, p.owner_id, u.phone AS owner_phone, p.settle_amount_cent, p.completed_at"). Joins("JOIN users u ON u.id = p.owner_id"). Where("p.status = ?", pickup.StatusCompleted). Where("p.completed_at >= ?", since). Where("p.settle_amount_cent > 0"). Where(`NOT EXISTS ( SELECT 1 FROM renter_growth_ledger g WHERE g.biz_key = CONCAT('pickup:', p.id, ':owner:pickup_completed') )`). Order("p.completed_at ASC, p.id ASC") if !includeInternal { query = query.Where("u.phone NOT LIKE ?", "admin:%") } 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 } func backfillOne(db *gorm.DB, pickupID uint64) (int64, error) { var points 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 || record.SettleAmountCent <= 0 { return nil } var err error points, err = rentergrowth.AwardPickupOwnerCompleted( tx, record.ID, record.PickupNo, record.OwnerID, record.SettleAmountCent, rentergrowth.SourcePickupCompleted, ) return err }) return points, err }