实现租客成长等级并优化展示
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
package rentergrowth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
ConfigKey = "renter.growth_level_rules"
|
||||
DefaultLevelCode = "normal"
|
||||
DefaultLevelName = "普通"
|
||||
DefaultDiscountBps = 10000
|
||||
DefaultPointsPerYuan = 1
|
||||
SourceOrderCompleted = "order_completed"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PointsPerYuan int64 `json:"points_per_yuan"`
|
||||
Levels []LevelRule `json:"levels"`
|
||||
}
|
||||
|
||||
type LevelRule struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
MinPoints int64 `json:"min_points"`
|
||||
DiscountBps int `json:"discount_bps"`
|
||||
}
|
||||
|
||||
type DiscountSnapshot struct {
|
||||
LevelCode string
|
||||
LevelName string
|
||||
DiscountBps int
|
||||
UserPoints int64
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Enabled: true,
|
||||
PointsPerYuan: DefaultPointsPerYuan,
|
||||
Levels: []LevelRule{
|
||||
{Code: DefaultLevelCode, Name: DefaultLevelName, MinPoints: 0, DiscountBps: DefaultDiscountBps},
|
||||
{Code: "platinum", Name: "铂金", MinPoints: 300, DiscountBps: 9900},
|
||||
{Code: "diamond", Name: "钻石", MinPoints: 1000, DiscountBps: 9800},
|
||||
{Code: "peak", Name: "巅峰", MinPoints: 5000, DiscountBps: 9500},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultConfigValue() string {
|
||||
raw, err := json.Marshal(DefaultConfig())
|
||||
if err != nil {
|
||||
return `{"enabled":true,"points_per_yuan":1,"levels":[{"code":"normal","name":"普通","min_points":0,"discount_bps":10000}]}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func ConfigForTx(tx *gorm.DB) (Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
if tx == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
var row model.SystemConfig
|
||||
if err := tx.Where("`key` = ?", ConfigKey).First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || isMissingConfigTableError(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
if strings.TrimSpace(row.Value) == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
return normalizeConfig(cfg), nil
|
||||
}
|
||||
|
||||
func SnapshotForUser(tx *gorm.DB, userID uint64) (DiscountSnapshot, error) {
|
||||
snapshot := DiscountSnapshot{
|
||||
LevelCode: DefaultLevelCode,
|
||||
LevelName: DefaultLevelName,
|
||||
DiscountBps: DefaultDiscountBps,
|
||||
}
|
||||
if tx == nil || userID == 0 {
|
||||
return snapshot, nil
|
||||
}
|
||||
cfg, err := ConfigForTx(tx)
|
||||
if err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return snapshot, nil
|
||||
}
|
||||
var user model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
level := LevelForPoints(cfg, user.RenterGrowthPoints)
|
||||
return DiscountSnapshot{
|
||||
LevelCode: level.Code,
|
||||
LevelName: level.Name,
|
||||
DiscountBps: level.DiscountBps,
|
||||
UserPoints: user.RenterGrowthPoints,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func CalculateDiscountCent(originalRentCent int64, platformFeeCent int64, discountBps int) int64 {
|
||||
if originalRentCent <= 0 || platformFeeCent <= 0 {
|
||||
return 0
|
||||
}
|
||||
if discountBps <= 0 || discountBps >= DefaultDiscountBps {
|
||||
return 0
|
||||
}
|
||||
discountedRentCent := originalRentCent * int64(discountBps) / DefaultDiscountBps
|
||||
discountCent := originalRentCent - discountedRentCent
|
||||
if discountCent < 0 {
|
||||
return 0
|
||||
}
|
||||
if discountCent > platformFeeCent {
|
||||
return platformFeeCent
|
||||
}
|
||||
return discountCent
|
||||
}
|
||||
|
||||
func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent 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
|
||||
}
|
||||
var existing model.RenterGrowthLedger
|
||||
err := tx.Where("order_id = ? AND source = ?", order.ID, source).First(&existing).Error
|
||||
if err == nil {
|
||||
order.GrowthPointsAwarded = existing.Points
|
||||
if !existing.CreatedAt.IsZero() {
|
||||
awardedAt := existing.CreatedAt
|
||||
order.GrowthPointsAwardedAt = &awardedAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := ConfigForTx(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
points := PointsForRent(actualRentCent, cfg.PointsPerYuan)
|
||||
if points <= 0 {
|
||||
return nil
|
||||
}
|
||||
var user model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, order.RenterID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
beforePoints := user.RenterGrowthPoints
|
||||
beforeLevel := LevelForPoints(cfg, beforePoints)
|
||||
afterPoints := beforePoints + points
|
||||
afterLevel := LevelForPoints(cfg, afterPoints)
|
||||
user.RenterGrowthPoints = afterPoints
|
||||
user.RenterGrowthLevel = afterLevel.Code
|
||||
if err := tx.Save(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
ledger := model.RenterGrowthLedger{
|
||||
UserID: order.RenterID,
|
||||
OrderID: order.ID,
|
||||
Points: points,
|
||||
BeforePoints: beforePoints,
|
||||
AfterPoints: afterPoints,
|
||||
BeforeLevel: beforeLevel.Code,
|
||||
AfterLevel: afterLevel.Code,
|
||||
Source: source,
|
||||
Remark: "租号订单完成发放成长积分",
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := tx.Create(&ledger).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
order.GrowthPointsAwarded = points
|
||||
order.GrowthPointsAwardedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func PointsForRent(actualRentCent int64, pointsPerYuan int64) int64 {
|
||||
if actualRentCent <= 0 {
|
||||
return 0
|
||||
}
|
||||
if pointsPerYuan <= 0 {
|
||||
pointsPerYuan = DefaultPointsPerYuan
|
||||
}
|
||||
return (actualRentCent / 100) * pointsPerYuan
|
||||
}
|
||||
|
||||
func LevelForPoints(cfg Config, points int64) LevelRule {
|
||||
cfg = normalizeConfig(cfg)
|
||||
if points < 0 {
|
||||
points = 0
|
||||
}
|
||||
current := cfg.Levels[0]
|
||||
for _, level := range cfg.Levels {
|
||||
if points >= level.MinPoints {
|
||||
current = level
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func normalizeConfig(cfg Config) Config {
|
||||
defaultCfg := DefaultConfig()
|
||||
if cfg.PointsPerYuan <= 0 {
|
||||
cfg.PointsPerYuan = defaultCfg.PointsPerYuan
|
||||
}
|
||||
if len(cfg.Levels) == 0 {
|
||||
cfg.Levels = defaultCfg.Levels
|
||||
}
|
||||
levels := make([]LevelRule, 0, len(cfg.Levels)+1)
|
||||
hasNormal := false
|
||||
for _, level := range cfg.Levels {
|
||||
level.Code = strings.TrimSpace(level.Code)
|
||||
level.Name = strings.TrimSpace(level.Name)
|
||||
if level.Code == "" {
|
||||
continue
|
||||
}
|
||||
if level.Name == "" {
|
||||
level.Name = level.Code
|
||||
}
|
||||
if level.MinPoints < 0 {
|
||||
level.MinPoints = 0
|
||||
}
|
||||
if level.DiscountBps <= 0 || level.DiscountBps > DefaultDiscountBps {
|
||||
level.DiscountBps = DefaultDiscountBps
|
||||
}
|
||||
if level.Code == DefaultLevelCode {
|
||||
hasNormal = true
|
||||
level.Name = DefaultLevelName
|
||||
level.MinPoints = 0
|
||||
}
|
||||
levels = append(levels, level)
|
||||
}
|
||||
if !hasNormal {
|
||||
levels = append(levels, LevelRule{
|
||||
Code: DefaultLevelCode,
|
||||
Name: DefaultLevelName,
|
||||
MinPoints: 0,
|
||||
DiscountBps: DefaultDiscountBps,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(levels, func(i, j int) bool {
|
||||
return levels[i].MinPoints < levels[j].MinPoints
|
||||
})
|
||||
cfg.Levels = levels
|
||||
return cfg
|
||||
}
|
||||
|
||||
func isMissingConfigTableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "no such table") ||
|
||||
strings.Contains(message, "doesn't exist") ||
|
||||
strings.Contains(message, "does not exist")
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package rentergrowth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestLevelForPointsStartsFromNormal(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cases := []struct {
|
||||
points int64
|
||||
want string
|
||||
}{
|
||||
{points: 0, want: DefaultLevelCode},
|
||||
{points: 299, want: DefaultLevelCode},
|
||||
{points: 300, want: "platinum"},
|
||||
{points: 1000, want: "diamond"},
|
||||
{points: 5000, want: "peak"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := LevelForPoints(cfg, tc.points)
|
||||
if got.Code != tc.want {
|
||||
t.Fatalf("LevelForPoints(%d) = %q, want %q", tc.points, got.Code, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDiscountCentCapsAtPlatformFee(t *testing.T) {
|
||||
if got := CalculateDiscountCent(10000, 60, 9900); got != 60 {
|
||||
t.Fatalf("discount = %d, want capped platform fee 60", got)
|
||||
}
|
||||
if got := CalculateDiscountCent(10000, 1000, 9900); got != 100 {
|
||||
t.Fatalf("discount = %d, want 100", got)
|
||||
}
|
||||
if got := CalculateDiscountCent(10000, 1000, DefaultDiscountBps); got != 0 {
|
||||
t.Fatalf("normal discount = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwardOrderCompletedUpdatesUserAndIsIdempotent(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.RentalOrder{},
|
||||
&model.SystemConfig{},
|
||||
&model.RenterGrowthLedger{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate failed: %v", err)
|
||||
}
|
||||
user := model.User{
|
||||
Phone: "13800008888",
|
||||
RenterGrowthPoints: 290,
|
||||
RenterGrowthLevel: DefaultLevelCode,
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("create user failed: %v", err)
|
||||
}
|
||||
order := model.RentalOrder{
|
||||
OrderNo: "RG202607260001",
|
||||
RenterID: user.ID,
|
||||
RentAmountCent: 2000,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("create order failed: %v", err)
|
||||
}
|
||||
|
||||
if err := AwardOrderCompleted(db, &order, 2000, SourceOrderCompleted); err != nil {
|
||||
t.Fatalf("AwardOrderCompleted() error = %v", err)
|
||||
}
|
||||
if order.GrowthPointsAwarded != 20 || order.GrowthPointsAwardedAt == nil {
|
||||
t.Fatalf("order awarded = %d at %v, want 20 with time", order.GrowthPointsAwarded, order.GrowthPointsAwardedAt)
|
||||
}
|
||||
var savedUser model.User
|
||||
if err := db.First(&savedUser, user.ID).Error; err != nil {
|
||||
t.Fatalf("find user failed: %v", err)
|
||||
}
|
||||
if savedUser.RenterGrowthPoints != 310 || savedUser.RenterGrowthLevel != "platinum" {
|
||||
t.Fatalf("user growth = %d/%s, want 310/platinum", savedUser.RenterGrowthPoints, savedUser.RenterGrowthLevel)
|
||||
}
|
||||
|
||||
freshOrder := order
|
||||
freshOrder.GrowthPointsAwarded = 0
|
||||
freshOrder.GrowthPointsAwardedAt = nil
|
||||
if err := AwardOrderCompleted(db, &freshOrder, 2000, SourceOrderCompleted); err != nil {
|
||||
t.Fatalf("second AwardOrderCompleted() error = %v", err)
|
||||
}
|
||||
if err := db.First(&savedUser, user.ID).Error; err != nil {
|
||||
t.Fatalf("find user after second award failed: %v", err)
|
||||
}
|
||||
if savedUser.RenterGrowthPoints != 310 {
|
||||
t.Fatalf("user points after second award = %d, want unchanged 310", savedUser.RenterGrowthPoints)
|
||||
}
|
||||
var ledgerCount int64
|
||||
if err := db.Model(&model.RenterGrowthLedger{}).Where("order_id = ?", order.ID).Count(&ledgerCount).Error; err != nil {
|
||||
t.Fatalf("count ledger failed: %v", err)
|
||||
}
|
||||
if ledgerCount != 1 {
|
||||
t.Fatalf("ledger count = %d, want 1", ledgerCount)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user