feat(pickup): 线下提号完成时发放号主成长积分

- 提号完成按号主实际结算金额发放积分,使用提号单维度幂等键防重复
- 增加线下提号积分补发脚本,默认预演,支持指定起始时间和单号
- 补充提号完成与积分幂等回归测试
This commit is contained in:
yml2213
2026-11-08 15:06:47 +08:00
parent d39f45621b
commit 732f34b23e
6 changed files with 348 additions and 0 deletions
@@ -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,