diff --git a/backend/Dockerfile b/backend/Dockerfile index ebc0846a..b37f029e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -23,6 +23,10 @@ RUN --mount=type=cache,target=/go/pkg/mod \ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=linux go build -o /out/hfb-rebate-backfill ./cmd/rebate-backfill +# 一次性运维脚本:优质号主上线后线下提号号主积分补发(默认预演,-apply 才写库) +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -o /out/hfb-pickup-points-backfill ./cmd/pickup-points-backfill FROM alpine:3.22 @@ -39,6 +43,7 @@ RUN sed -i 's#https\?://dl-cdn.alpinelinux.org/alpine#https://mirrors.aliyun.com COPY --from=build /out/hfb-api /app/hfb-api COPY --from=build /out/hfb-storage-migrate /app/hfb-storage-migrate COPY --from=build /out/hfb-rebate-backfill /app/hfb-rebate-backfill +COPY --from=build /out/hfb-pickup-points-backfill /app/hfb-pickup-points-backfill COPY --from=build /go/bin/goose /app/goose COPY --from=build /src/migrations /app/migrations USER app:app diff --git a/backend/cmd/pickup-points-backfill/main.go b/backend/cmd/pickup-points-backfill/main.go new file mode 100644 index 00000000..3ce5ca26 --- /dev/null +++ b/backend/cmd/pickup-points-backfill/main.go @@ -0,0 +1,177 @@ +// 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 +} diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go index a93c0225..84450369 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -16,6 +16,7 @@ import ( "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/invitation" "hfb_sys/backend/internal/modules/notification" + "hfb_sys/backend/internal/modules/rentergrowth" "hfb_sys/backend/internal/modules/wallet" "hfb_sys/backend/internal/timeutil" "hfb_sys/backend/pkg/money" @@ -307,6 +308,17 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete } } + if _, err := rentergrowth.AwardPickupOwnerCompleted( + tx, + pickup.ID, + pickup.PickupNo, + pickup.OwnerID, + pickup.SettleAmountCent, + rentergrowth.SourcePickupCompleted, + ); err != nil { + return err + } + // 邀请返利:线下提号不走正常订单结账流程,必须在这里补发号主(卖家)侧返利, // 否则号主邀请人永远拿不到提号产生的分成。 // 基准取「网站加价」与「线下利润」的较小值,避免按虚高的单一利润口径多算。 diff --git a/backend/internal/modules/pickup/repository_test.go b/backend/internal/modules/pickup/repository_test.go index 007f413f..97f52d92 100644 --- a/backend/internal/modules/pickup/repository_test.go +++ b/backend/internal/modules/pickup/repository_test.go @@ -3,6 +3,7 @@ package pickup import ( "encoding/json" "errors" + "strconv" "strings" "testing" "time" @@ -363,6 +364,20 @@ func TestRepositoryCompleteInternalPickupCreditsWallet(t *testing.T) { t.Fatalf("站内提号应生成钱包流水: %v", err) } assertInt64(t, "站内提号钱包入账", ledger.AmountCent, 12000) + + var owner model.User + if err := db.First(&owner, listing.OwnerID).Error; err != nil { + t.Fatalf("读取提号号主失败: %v", err) + } + assertInt64(t, "提号号主成长积分", owner.RenterGrowthPoints, 120) + var growthLedger model.RenterGrowthLedger + if err := db.Where("biz_key = ?", "pickup:"+strconv.FormatUint(pickup.ID, 10)+":owner:pickup_completed"). + First(&growthLedger).Error; err != nil { + t.Fatalf("提号应生成号主积分流水: %v", err) + } + if growthLedger.Role != "owner" || growthLedger.Source != "pickup_completed" || growthLedger.Points != 120 { + t.Fatalf("提号积分流水 = %#v", growthLedger) + } } // 线下提号不走正常订单结账流程,完成时必须同步给号主邀请人发放返利, @@ -736,6 +751,8 @@ func newPickupTestRepo(t *testing.T) (*Repository, *gorm.DB) { &model.ProcessEvent{}, &model.Notification{}, &model.ListingStatusEvent{}, + &model.SystemConfig{}, + &model.RenterGrowthLedger{}, ); err != nil { t.Fatalf("迁移测试表失败: %v", err) } diff --git a/backend/internal/modules/rentergrowth/growth.go b/backend/internal/modules/rentergrowth/growth.go index 39d04c9e..b88a4c1d 100644 --- a/backend/internal/modules/rentergrowth/growth.go +++ b/backend/internal/modules/rentergrowth/growth.go @@ -31,6 +31,7 @@ const ( SourceOrderCompleted = "order_completed" SourceOrderRefunded = "order_refunded" SourceAdminAdjustment = "admin_adjustment" + SourcePickupCompleted = "pickup_completed" ) var fixedLevels = []struct { @@ -279,6 +280,83 @@ func AwardOrderBothCompleted(tx *gorm.DB, order *model.RentalOrder, renterBasisC return nil } +// AwardPickupOwnerCompleted 在线下提号完成时给号主发放积分。 +// 提号没有租客侧,因此只发 owner;basisCent 使用号主实际结算金额。 +// biz_key 按提号单 + owner + source 唯一,保证完成回调和补发脚本重复执行不会重复加分。 +func AwardPickupOwnerCompleted(tx *gorm.DB, pickupID uint64, pickupNo string, ownerID uint64, basisCent int64, source string) (int64, error) { + if tx == nil || pickupID == 0 || ownerID == 0 || basisCent <= 0 { + return 0, nil + } + if strings.TrimSpace(source) == "" { + source = SourcePickupCompleted + } + cfg, err := ConfigForTx(tx) + if err != nil { + return 0, err + } + bizKey := fmt.Sprintf("pickup:%d:owner:%s", pickupID, source) + var existing model.RenterGrowthLedger + err = tx.Where("biz_key = ?", bizKey).First(&existing).Error + if err == nil { + return existing.Points, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return 0, err + } + if !cfg.Enabled { + return 0, nil + } + + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, ownerID).Error; err != nil { + return 0, err + } + beforePoints := user.RenterGrowthPoints + beforeAvailable := user.PointsAvailable + beforeLevel := EffectiveLevelForUser(cfg, beforePoints, user.RenterGrowthLevelOverride) + multiplierBps := beforeLevel.PointsMultiplierBps + points := PointsForAmount(basisCent, cfg.PointsPerYuan, multiplierBps) + if points <= 0 { + return 0, nil + } + afterPoints := beforePoints + points + afterLevel := LevelForUserAfterPoints(cfg, afterPoints, user.RenterGrowthLevelOverride) + user.RenterGrowthPoints = afterPoints + user.RenterGrowthLevel = afterLevel.Code + user.PointsAvailable = beforeAvailable + points + if err := tx.Save(&user).Error; err != nil { + return 0, err + } + + now := time.Now() + remark := "线下提号完成发放积分" + if no := strings.TrimSpace(pickupNo); no != "" { + remark = fmt.Sprintf("线下提号 %s 完成发放积分", no) + } + ledger := model.RenterGrowthLedger{ + UserID: ownerID, + Role: "owner", + Points: points, + BasisAmountCent: basisCent, + PointsPerYuan: cfg.PointsPerYuan, + PointsMultiplierBps: multiplierBps, + BeforePoints: beforePoints, + AfterPoints: afterPoints, + AvailableBefore: beforeAvailable, + AvailableAfter: user.PointsAvailable, + BeforeLevel: beforeLevel.Code, + AfterLevel: afterLevel.Code, + Source: source, + BizKey: &bizKey, + Remark: remark, + CreatedAt: now, + } + if err := tx.Create(&ledger).Error; err != nil { + return 0, err + } + return points, nil +} + func awardOrderSide(tx *gorm.DB, order *model.RentalOrder, cfg Config, role string, userID uint64, basisCent int64, source string) error { basisCent = maxInt64(basisCent, 0) bizKey := fmt.Sprintf("order:%d:%s:%s", order.ID, role, source) diff --git a/backend/internal/modules/rentergrowth/growth_test.go b/backend/internal/modules/rentergrowth/growth_test.go index fbf88541..dca04245 100644 --- a/backend/internal/modules/rentergrowth/growth_test.go +++ b/backend/internal/modules/rentergrowth/growth_test.go @@ -312,6 +312,65 @@ func TestAwardOrderBothSidesUsesActualBasesAndMultipliers(t *testing.T) { } } +func TestAwardPickupOwnerCompletedIsIdempotent(t *testing.T) { + db := database.NewTestDB() + if err := database.AutoMigrateIfMissing(db, + &model.User{}, + &model.SystemConfig{}, + &model.RenterGrowthLedger{}, + ); err != nil { + t.Fatalf("migrate failed: %v", err) + } + owner := model.User{ + Phone: "13800006661", + RenterGrowthPoints: 290, + RenterGrowthLevel: DefaultLevelCode, + } + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("create owner failed: %v", err) + } + + points, err := AwardPickupOwnerCompleted(db, 9001, "PK9001", owner.ID, 12000, SourcePickupCompleted) + if err != nil { + t.Fatalf("AwardPickupOwnerCompleted() error = %v", err) + } + if points != 120 { + t.Fatalf("points = %d, want 120", points) + } + // 重复执行必须返回首次发放积分,且不重复加积分。 + points, err = AwardPickupOwnerCompleted(db, 9001, "PK9001", owner.ID, 12000, SourcePickupCompleted) + if err != nil { + t.Fatalf("second AwardPickupOwnerCompleted() error = %v", err) + } + if points != 120 { + t.Fatalf("second points = %d, want 120", points) + } + + var saved model.User + if err := db.First(&saved, owner.ID).Error; err != nil { + t.Fatalf("find owner failed: %v", err) + } + if saved.RenterGrowthPoints != 410 || saved.PointsAvailable != 120 { + t.Fatalf("owner points = lifetime %d available %d, want 410/120", saved.RenterGrowthPoints, saved.PointsAvailable) + } + var ledger model.RenterGrowthLedger + if err := db.Where("biz_key = ?", "pickup:9001:owner:pickup_completed").First(&ledger).Error; err != nil { + t.Fatalf("find pickup ledger failed: %v", err) + } + if ledger.Role != "owner" || ledger.Source != SourcePickupCompleted || ledger.BasisAmountCent != 12000 { + t.Fatalf("ledger = %#v", ledger) + } + var count int64 + if err := db.Model(&model.RenterGrowthLedger{}). + Where("biz_key = ?", "pickup:9001:owner:pickup_completed"). + Count(&count).Error; err != nil { + t.Fatalf("count ledger failed: %v", err) + } + if count != 1 { + t.Fatalf("pickup ledger count = %d, want 1", count) + } +} + func TestRevokeOrderCompletedRecordsIdempotencyWhenUserHasNoPoints(t *testing.T) { db := database.NewTestDB() if err := database.AutoMigrateIfMissing(db,