feat(pickup): 线下提号完成时发放号主成长积分
- 提号完成按号主实际结算金额发放积分,使用提号单维度幂等键防重复 - 增加线下提号积分补发脚本,默认预演,支持指定起始时间和单号 - 补充提号完成与积分幂等回归测试
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 邀请返利:线下提号不走正常订单结账流程,必须在这里补发号主(卖家)侧返利,
|
||||
// 否则号主邀请人永远拿不到提号产生的分成。
|
||||
// 基准取「网站加价」与「线下利润」的较小值,避免按虚高的单一利润口径多算。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user