feat(points): 增加双边积分与虚拟积分商城

This commit is contained in:
yml2213
2026-11-04 19:21:02 +08:00
parent c00d691301
commit d5abe67f60
42 changed files with 2593 additions and 316 deletions
+197 -74
View File
@@ -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,