Files
hfb_sys/backend/cmd/pickup-points-backfill/main.go
T
yml2213 732f34b23e feat(pickup): 线下提号完成时发放号主成长积分
- 提号完成按号主实际结算金额发放积分,使用提号单维度幂等键防重复
- 增加线下提号积分补发脚本,默认预演,支持指定起始时间和单号
- 补充提号完成与积分幂等回归测试
2026-11-08 15:06:47 +08:00

178 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// pickup-points-backfill 为「线下提号」补发号主成长积分。
//
// 背景:优质号主功能上线前,线下提号完成只做钱包结算和邀请返利,没有发放成长积分。
// 本脚本只补发指定时间之后、真实号主(排除 admin: 内部账号)的已完成提号单。
//
// 幂等:积分流水 biz_key = pickup:<id>: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
}