feat(points): 增加双边积分与虚拟积分商城
This commit is contained in:
@@ -3,6 +3,7 @@ package rentergrowth
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -19,6 +20,8 @@ const (
|
||||
DefaultLevelName = "普通"
|
||||
DefaultDiscountBps = 10000
|
||||
DefaultPointsPerYuan = 1
|
||||
DefaultMultiplierBps = 10_000
|
||||
MaxPointsMultiplierBps = 100_000
|
||||
DefaultPlatinumQuotaCent = 50_000
|
||||
DefaultDiamondQuotaCent = 100_000
|
||||
DefaultPeakQuotaCent = 150_000
|
||||
@@ -50,6 +53,7 @@ type LevelRule struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
MinPoints int64 `json:"min_points"`
|
||||
PointsMultiplierBps int `json:"points_multiplier_bps"`
|
||||
DiscountBps int `json:"discount_bps"`
|
||||
DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"`
|
||||
}
|
||||
@@ -69,10 +73,10 @@ func DefaultConfig() Config {
|
||||
Enabled: true,
|
||||
PointsPerYuan: DefaultPointsPerYuan,
|
||||
Levels: []LevelRule{
|
||||
{Code: DefaultLevelCode, Name: DefaultLevelName, MinPoints: 0, DiscountBps: DefaultDiscountBps, DepositFreeQuotaCent: 0},
|
||||
{Code: "platinum", Name: "铂金", MinPoints: 300, DiscountBps: 9900, DepositFreeQuotaCent: DefaultPlatinumQuotaCent},
|
||||
{Code: "diamond", Name: "钻石", MinPoints: 1000, DiscountBps: 9800, DepositFreeQuotaCent: DefaultDiamondQuotaCent},
|
||||
{Code: "peak", Name: "巅峰", MinPoints: 5000, DiscountBps: 9500, DepositFreeQuotaCent: DefaultPeakQuotaCent},
|
||||
{Code: DefaultLevelCode, Name: DefaultLevelName, MinPoints: 0, PointsMultiplierBps: 10_000, DiscountBps: DefaultDiscountBps, DepositFreeQuotaCent: 0},
|
||||
{Code: "platinum", Name: "铂金", MinPoints: 300, PointsMultiplierBps: 12_000, DiscountBps: 9900, DepositFreeQuotaCent: DefaultPlatinumQuotaCent},
|
||||
{Code: "diamond", Name: "钻石", MinPoints: 1000, PointsMultiplierBps: 15_000, DiscountBps: 9800, DepositFreeQuotaCent: DefaultDiamondQuotaCent},
|
||||
{Code: "peak", Name: "巅峰", MinPoints: 5000, PointsMultiplierBps: 20_000, DiscountBps: 9500, DepositFreeQuotaCent: DefaultPeakQuotaCent},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -83,6 +87,7 @@ func (level *LevelRule) UnmarshalJSON(data []byte) error {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
MinPoints int64 `json:"min_points"`
|
||||
PointsMultiplierBps *int `json:"points_multiplier_bps"`
|
||||
DiscountBps int `json:"discount_bps"`
|
||||
DepositFreeQuotaCent *int64 `json:"deposit_free_quota_cent"`
|
||||
}
|
||||
@@ -94,10 +99,15 @@ func (level *LevelRule) UnmarshalJSON(data []byte) error {
|
||||
if raw.DepositFreeQuotaCent != nil {
|
||||
quotaCent = *raw.DepositFreeQuotaCent
|
||||
}
|
||||
multiplierBps := defaultPointsMultiplierBps(raw.Code)
|
||||
if raw.PointsMultiplierBps != nil {
|
||||
multiplierBps = *raw.PointsMultiplierBps
|
||||
}
|
||||
*level = LevelRule{
|
||||
Code: raw.Code,
|
||||
Name: raw.Name,
|
||||
MinPoints: raw.MinPoints,
|
||||
PointsMultiplierBps: multiplierBps,
|
||||
DiscountBps: raw.DiscountBps,
|
||||
DepositFreeQuotaCent: quotaCent,
|
||||
}
|
||||
@@ -151,7 +161,7 @@ func ValidateConfig(cfg Config) error {
|
||||
if !ok || strings.TrimSpace(level.Name) != fixed.Name {
|
||||
return errors.New("missing renter growth level")
|
||||
}
|
||||
if level.MinPoints < 0 || level.MinPoints > MaxLevelPoints || level.DiscountBps <= 0 || level.DiscountBps > DefaultDiscountBps || level.DepositFreeQuotaCent < 0 || level.DepositFreeQuotaCent > MaxLevelQuotaCent {
|
||||
if level.MinPoints < 0 || level.MinPoints > MaxLevelPoints || level.PointsMultiplierBps <= 0 || level.PointsMultiplierBps > MaxPointsMultiplierBps || level.DiscountBps <= 0 || level.DiscountBps > DefaultDiscountBps || level.DepositFreeQuotaCent < 0 || level.DepositFreeQuotaCent > MaxLevelQuotaCent {
|
||||
return errors.New("invalid renter growth level value")
|
||||
}
|
||||
if index == 0 {
|
||||
@@ -161,7 +171,7 @@ func ValidateConfig(cfg Config) error {
|
||||
previous = level
|
||||
continue
|
||||
}
|
||||
if level.MinPoints <= previous.MinPoints || level.DiscountBps > previous.DiscountBps || level.DepositFreeQuotaCent < previous.DepositFreeQuotaCent {
|
||||
if level.MinPoints <= previous.MinPoints || level.PointsMultiplierBps < previous.PointsMultiplierBps || level.DiscountBps > previous.DiscountBps || level.DepositFreeQuotaCent < previous.DepositFreeQuotaCent {
|
||||
return errors.New("renter growth levels must increase")
|
||||
}
|
||||
previous = level
|
||||
@@ -245,120 +255,197 @@ func CalculateDiscountCent(originalRentCent int64, platformFeeCent int64, discou
|
||||
}
|
||||
|
||||
func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualPureCoinCent int64, source string) error {
|
||||
return AwardOrderBothCompleted(tx, order, actualPureCoinCent, 0, source)
|
||||
}
|
||||
|
||||
// AwardOrderBothCompleted 按租客实际支出和卖家实际收入分别发放积分。
|
||||
func AwardOrderBothCompleted(tx *gorm.DB, order *model.RentalOrder, renterBasisCent int64, ownerBasisCent int64, source string) error {
|
||||
if tx == nil || order == nil || order.ID == 0 || order.RenterID == 0 {
|
||||
return nil
|
||||
}
|
||||
if order.GrowthPointsAwarded > 0 || order.GrowthPointsAwardedAt != nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(source) == "" {
|
||||
source = SourceOrderCompleted
|
||||
}
|
||||
cfg, err := ConfigForTx(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := awardOrderSide(tx, order, cfg, "renter", order.RenterID, renterBasisCent, source); err != nil {
|
||||
return err
|
||||
}
|
||||
if order.OwnerID > 0 && order.OwnerID != order.RenterID {
|
||||
return awardOrderSide(tx, order, cfg, "owner", order.OwnerID, ownerBasisCent, source)
|
||||
}
|
||||
return 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)
|
||||
var existing model.RenterGrowthLedger
|
||||
err := tx.Where("order_id = ? AND source = ?", order.ID, source).First(&existing).Error
|
||||
err := tx.Where("biz_key = ?", bizKey).First(&existing).Error
|
||||
if err == nil {
|
||||
order.GrowthPointsAwarded = existing.Points
|
||||
if !existing.CreatedAt.IsZero() {
|
||||
awardedAt := existing.CreatedAt
|
||||
order.GrowthPointsAwardedAt = &awardedAt
|
||||
}
|
||||
setOrderPointsSnapshot(order, role, existing.BasisAmountCent, existing.PointsPerYuan, existing.PointsMultiplierBps, existing.Points, existing.CreatedAt)
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := ConfigForTx(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
if role == "renter" {
|
||||
order.GrowthPointsBasisCent = basisCent
|
||||
order.GrowthPointsPerYuan = cfg.PointsPerYuan
|
||||
} else {
|
||||
order.OwnerGrowthPointsBasisCent = basisCent
|
||||
order.OwnerGrowthPointsPerYuan = cfg.PointsPerYuan
|
||||
}
|
||||
order.GrowthPointsBasisCent = maxInt64(actualPureCoinCent, 0)
|
||||
order.GrowthPointsPerYuan = cfg.PointsPerYuan
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
points := PointsForPureCoin(actualPureCoinCent, cfg.PointsPerYuan)
|
||||
if points <= 0 {
|
||||
if !cfg.Enabled || basisCent <= 0 {
|
||||
return nil
|
||||
}
|
||||
var user model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, order.RenterID).Error; err != nil {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
beforePoints := user.RenterGrowthPoints
|
||||
beforeAvailable := user.PointsAvailable
|
||||
beforeLevel := LevelForPoints(cfg, beforePoints)
|
||||
multiplierBps := beforeLevel.PointsMultiplierBps
|
||||
points := PointsForAmount(basisCent, cfg.PointsPerYuan, multiplierBps)
|
||||
if points <= 0 {
|
||||
if role == "renter" {
|
||||
order.GrowthPointsMultiplierBps = multiplierBps
|
||||
} else {
|
||||
order.OwnerGrowthPointsMultiplierBps = multiplierBps
|
||||
}
|
||||
return nil
|
||||
}
|
||||
afterPoints := beforePoints + points
|
||||
afterLevel := LevelForPoints(cfg, afterPoints)
|
||||
user.RenterGrowthPoints = afterPoints
|
||||
user.RenterGrowthLevel = afterLevel.Code
|
||||
user.PointsAvailable = beforeAvailable + points
|
||||
if err := tx.Save(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
ledger := model.RenterGrowthLedger{
|
||||
UserID: order.RenterID,
|
||||
OrderID: &order.ID,
|
||||
Points: points,
|
||||
BasisAmountCent: actualPureCoinCent,
|
||||
PointsPerYuan: cfg.PointsPerYuan,
|
||||
BeforePoints: beforePoints,
|
||||
AfterPoints: afterPoints,
|
||||
BeforeLevel: beforeLevel.Code,
|
||||
AfterLevel: afterLevel.Code,
|
||||
Source: source,
|
||||
Remark: "租号订单完成发放成长积分",
|
||||
CreatedAt: now,
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
OrderID: &order.ID,
|
||||
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: "订单完成发放积分",
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := tx.Create(&ledger).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
order.GrowthPointsAwarded = points
|
||||
order.GrowthPointsAwardedAt = &now
|
||||
setOrderPointsSnapshot(order, role, basisCent, cfg.PointsPerYuan, multiplierBps, points, now)
|
||||
return nil
|
||||
}
|
||||
|
||||
func setOrderPointsSnapshot(order *model.RentalOrder, role string, basisCent, perYuan int64, multiplierBps int, points int64, awardedAt time.Time) {
|
||||
if role == "owner" {
|
||||
order.OwnerGrowthPointsBasisCent = basisCent
|
||||
order.OwnerGrowthPointsPerYuan = perYuan
|
||||
order.OwnerGrowthPointsMultiplierBps = multiplierBps
|
||||
order.OwnerGrowthPointsAwarded = points
|
||||
if !awardedAt.IsZero() {
|
||||
order.OwnerGrowthPointsAwardedAt = &awardedAt
|
||||
}
|
||||
return
|
||||
}
|
||||
order.GrowthPointsBasisCent = basisCent
|
||||
order.GrowthPointsPerYuan = perYuan
|
||||
order.GrowthPointsMultiplierBps = multiplierBps
|
||||
order.GrowthPointsAwarded = points
|
||||
if !awardedAt.IsZero() {
|
||||
order.GrowthPointsAwardedAt = &awardedAt
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeOrderCompleted 在已完成订单被全额人工退款后扣回该单已发放的成长积分。
|
||||
func RevokeOrderCompleted(tx *gorm.DB, order *model.RentalOrder) error {
|
||||
if tx == nil || order == nil || order.ID == 0 || order.RenterID == 0 {
|
||||
return nil
|
||||
}
|
||||
cfg, err := ConfigForTx(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, side := range []struct {
|
||||
role string
|
||||
userID uint64
|
||||
}{
|
||||
{role: "renter", userID: order.RenterID},
|
||||
{role: "owner", userID: order.OwnerID},
|
||||
} {
|
||||
if side.userID == 0 {
|
||||
continue
|
||||
}
|
||||
if err := revokeOrderSide(tx, order, cfg, side.role, side.userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func revokeOrderSide(tx *gorm.DB, order *model.RentalOrder, cfg Config, role string, userID uint64) error {
|
||||
bizKey := fmt.Sprintf("order:%d:%s:%s", order.ID, role, SourceOrderRefunded)
|
||||
var existing model.RenterGrowthLedger
|
||||
err := tx.Where("order_id = ? AND source = ?", order.ID, SourceOrderRefunded).First(&existing).Error
|
||||
err := tx.Where("biz_key = ?", bizKey).First(&existing).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
points := order.GrowthPointsAwarded
|
||||
if points <= 0 {
|
||||
var awarded model.RenterGrowthLedger
|
||||
if err := tx.Where("order_id = ? AND source = ?", order.ID, SourceOrderCompleted).First(&awarded).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
var awarded model.RenterGrowthLedger
|
||||
if err := tx.Where(
|
||||
"order_id = ? AND role = ? AND source = ?",
|
||||
order.ID,
|
||||
role,
|
||||
SourceOrderCompleted,
|
||||
).First(&awarded).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if role != "renter" || order.GrowthPointsAwarded <= 0 {
|
||||
return nil
|
||||
}
|
||||
awarded = model.RenterGrowthLedger{
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
OrderID: &order.ID,
|
||||
Points: order.GrowthPointsAwarded,
|
||||
BasisAmountCent: order.GrowthPointsBasisCent,
|
||||
PointsPerYuan: order.GrowthPointsPerYuan,
|
||||
PointsMultiplierBps: order.GrowthPointsMultiplierBps,
|
||||
Source: SourceOrderCompleted,
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
points = awarded.Points
|
||||
}
|
||||
points := awarded.Points
|
||||
if points <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg, err := ConfigForTx(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var user model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, order.RenterID).Error; err != nil {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
beforePoints := maxInt64(user.RenterGrowthPoints, 0)
|
||||
revokedPoints := points
|
||||
if revokedPoints > beforePoints {
|
||||
revokedPoints = beforePoints
|
||||
}
|
||||
beforeAvailable := maxInt64(user.PointsAvailable, 0)
|
||||
revokedPoints := minInt64(points, beforePoints)
|
||||
revokedAvailable := minInt64(points, beforeAvailable)
|
||||
beforeLevel := LevelForPoints(cfg, beforePoints)
|
||||
afterPoints := beforePoints - revokedPoints
|
||||
afterLevel := LevelForPoints(cfg, afterPoints)
|
||||
@@ -368,34 +455,46 @@ func RevokeOrderCompleted(tx *gorm.DB, order *model.RentalOrder) error {
|
||||
}
|
||||
user.RenterGrowthPoints = afterPoints
|
||||
user.RenterGrowthLevel = afterLevel.Code
|
||||
user.PointsAvailable = beforeAvailable - revokedAvailable
|
||||
if err := tx.Save(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
orderID := order.ID
|
||||
return tx.Create(&model.RenterGrowthLedger{
|
||||
UserID: order.RenterID,
|
||||
OrderID: &orderID,
|
||||
Points: -revokedPoints,
|
||||
BasisAmountCent: order.GrowthPointsBasisCent,
|
||||
PointsPerYuan: order.GrowthPointsPerYuan,
|
||||
BeforePoints: beforePoints,
|
||||
AfterPoints: afterPoints,
|
||||
BeforeLevel: beforeLevel.Code,
|
||||
AfterLevel: afterLevel.Code,
|
||||
Source: SourceOrderRefunded,
|
||||
Remark: "已完成订单全额退款扣回成长积分",
|
||||
CreatedAt: time.Now(),
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
OrderID: &order.ID,
|
||||
Points: -revokedPoints,
|
||||
BasisAmountCent: awarded.BasisAmountCent,
|
||||
PointsPerYuan: awarded.PointsPerYuan,
|
||||
PointsMultiplierBps: awarded.PointsMultiplierBps,
|
||||
BeforePoints: beforePoints,
|
||||
AfterPoints: afterPoints,
|
||||
AvailableBefore: beforeAvailable,
|
||||
AvailableAfter: user.PointsAvailable,
|
||||
BeforeLevel: beforeLevel.Code,
|
||||
AfterLevel: afterLevel.Code,
|
||||
Source: SourceOrderRefunded,
|
||||
BizKey: &bizKey,
|
||||
Remark: "已完成订单全额退款扣回积分",
|
||||
CreatedAt: time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func PointsForPureCoin(actualPureCoinCent int64, pointsPerYuan int64) int64 {
|
||||
if actualPureCoinCent <= 0 {
|
||||
return PointsForAmount(actualPureCoinCent, pointsPerYuan, DefaultMultiplierBps)
|
||||
}
|
||||
|
||||
func PointsForAmount(basisCent int64, pointsPerYuan int64, multiplierBps int) int64 {
|
||||
if basisCent <= 0 {
|
||||
return 0
|
||||
}
|
||||
if pointsPerYuan <= 0 {
|
||||
pointsPerYuan = DefaultPointsPerYuan
|
||||
}
|
||||
return (actualPureCoinCent / 100) * pointsPerYuan
|
||||
if multiplierBps <= 0 {
|
||||
multiplierBps = DefaultMultiplierBps
|
||||
}
|
||||
return basisCent * pointsPerYuan * int64(multiplierBps) / (100 * DefaultMultiplierBps)
|
||||
}
|
||||
|
||||
// PointsForRent 保留旧调用兼容,积分基数现统一解释为实际纯币金额。
|
||||
@@ -463,6 +562,9 @@ func normalizeConfig(cfg Config) Config {
|
||||
if level.MinPoints < 0 {
|
||||
level.MinPoints = 0
|
||||
}
|
||||
if level.PointsMultiplierBps <= 0 || level.PointsMultiplierBps > MaxPointsMultiplierBps {
|
||||
level.PointsMultiplierBps = defaultPointsMultiplierBps(level.Code)
|
||||
}
|
||||
if level.DiscountBps <= 0 || level.DiscountBps > DefaultDiscountBps {
|
||||
level.DiscountBps = DefaultDiscountBps
|
||||
}
|
||||
@@ -481,6 +583,7 @@ func normalizeConfig(cfg Config) Config {
|
||||
Code: DefaultLevelCode,
|
||||
Name: DefaultLevelName,
|
||||
MinPoints: 0,
|
||||
PointsMultiplierBps: DefaultMultiplierBps,
|
||||
DiscountBps: DefaultDiscountBps,
|
||||
DepositFreeQuotaCent: 0,
|
||||
})
|
||||
@@ -505,6 +608,19 @@ func defaultDepositFreeQuotaCent(code string) int64 {
|
||||
}
|
||||
}
|
||||
|
||||
func defaultPointsMultiplierBps(code string) int {
|
||||
switch strings.TrimSpace(code) {
|
||||
case "platinum":
|
||||
return 12_000
|
||||
case "diamond":
|
||||
return 15_000
|
||||
case "peak":
|
||||
return 20_000
|
||||
default:
|
||||
return DefaultMultiplierBps
|
||||
}
|
||||
}
|
||||
|
||||
func addQuotaCent(left int64, right int64) int64 {
|
||||
left = maxInt64(left, 0)
|
||||
right = maxInt64(right, 0)
|
||||
@@ -522,6 +638,13 @@ func maxInt64(left int64, right int64) int64 {
|
||||
return right
|
||||
}
|
||||
|
||||
func minInt64(left int64, right int64) int64 {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func isMissingConfigTableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
@@ -21,6 +21,17 @@ func TestDefaultConfigIncludesDepositFreeQuota(t *testing.T) {
|
||||
t.Fatalf("等级 %s 免押额度 = %d, want %d", level.Code, level.DepositFreeQuotaCent, wants[level.Code])
|
||||
}
|
||||
}
|
||||
multipliers := map[string]int{
|
||||
DefaultLevelCode: 10_000,
|
||||
"platinum": 12_000,
|
||||
"diamond": 15_000,
|
||||
"peak": 20_000,
|
||||
}
|
||||
for _, level := range cfg.Levels {
|
||||
if level.PointsMultiplierBps != multipliers[level.Code] {
|
||||
t.Fatalf("等级 %s 积分倍率 = %d, want %d", level.Code, level.PointsMultiplierBps, multipliers[level.Code])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyConfigBackfillsQuotaButKeepsExplicitZero(t *testing.T) {
|
||||
@@ -32,6 +43,9 @@ func TestLegacyConfigBackfillsQuotaButKeepsExplicitZero(t *testing.T) {
|
||||
if LevelForPoints(cfg, 300).DepositFreeQuotaCent != DefaultPlatinumQuotaCent {
|
||||
t.Fatalf("旧配置未补齐铂金免押额度")
|
||||
}
|
||||
if LevelForPoints(cfg, 300).PointsMultiplierBps != 12_000 {
|
||||
t.Fatalf("旧配置未补齐铂金积分倍率")
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(legacy), &raw); err != nil {
|
||||
@@ -210,6 +224,52 @@ func TestAwardOrderCompletedUpdatesUserAndIsIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwardOrderBothSidesUsesActualBasesAndMultipliers(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
renter := model.User{Phone: "13800007771"}
|
||||
owner := model.User{
|
||||
Phone: "13800007772",
|
||||
RenterGrowthPoints: 1000,
|
||||
RenterGrowthLevel: "diamond",
|
||||
}
|
||||
if err := db.Create(&renter).Error; err != nil {
|
||||
t.Fatalf("create renter failed: %v", err)
|
||||
}
|
||||
if err := db.Create(&owner).Error; err != nil {
|
||||
t.Fatalf("create owner failed: %v", err)
|
||||
}
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "RG202611040001",
|
||||
RenterID: renter.ID,
|
||||
OwnerID: owner.ID,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("create order failed: %v", err)
|
||||
}
|
||||
if err := AwardOrderBothCompleted(db, &order, 10_000, 5_000, SourceOrderCompleted); err != nil {
|
||||
t.Fatalf("award both sides failed: %v", err)
|
||||
}
|
||||
if order.GrowthPointsAwarded != 100 || order.GrowthPointsMultiplierBps != 10_000 {
|
||||
t.Fatalf("renter points snapshot = %d/%d, want 100/10000", order.GrowthPointsAwarded, order.GrowthPointsMultiplierBps)
|
||||
}
|
||||
if order.OwnerGrowthPointsAwarded != 75 || order.OwnerGrowthPointsMultiplierBps != 15_000 {
|
||||
t.Fatalf("owner points snapshot = %d/%d, want 75/15000", order.OwnerGrowthPointsAwarded, order.OwnerGrowthPointsMultiplierBps)
|
||||
}
|
||||
var savedRenter, savedOwner model.User
|
||||
if err := db.First(&savedRenter, renter.ID).Error; err != nil {
|
||||
t.Fatalf("find renter failed: %v", err)
|
||||
}
|
||||
if err := db.First(&savedOwner, owner.ID).Error; err != nil {
|
||||
t.Fatalf("find owner failed: %v", err)
|
||||
}
|
||||
if savedRenter.PointsAvailable != 100 || savedRenter.RenterGrowthPoints != 100 {
|
||||
t.Fatalf("renter points = available %d lifetime %d, want 100/100", savedRenter.PointsAvailable, savedRenter.RenterGrowthPoints)
|
||||
}
|
||||
if savedOwner.PointsAvailable != 75 || savedOwner.RenterGrowthPoints != 1075 {
|
||||
t.Fatalf("owner points = available %d lifetime %d, want 75/1075", savedOwner.PointsAvailable, savedOwner.RenterGrowthPoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeOrderCompletedRecordsIdempotencyWhenUserHasNoPoints(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
if err := database.AutoMigrateIfMissing(db,
|
||||
|
||||
Reference in New Issue
Block a user