实现租客成长等级并优化展示

This commit is contained in:
yml2213
2026-07-26 11:25:25 +08:00
parent 756fa342cb
commit 321f0781cf
27 changed files with 948 additions and 16 deletions
+2
View File
@@ -51,6 +51,7 @@ func MigrateRentalTransactionTestSchema(db *gorm.DB) error {
}
return db.AutoMigrate(
&model.RentalOrder{},
&model.SystemConfig{},
&model.PaymentOrder{},
&model.Notification{},
&model.AdminNotification{},
@@ -58,6 +59,7 @@ func MigrateRentalTransactionTestSchema(db *gorm.DB) error {
&model.OrderCheckout{},
&model.WalletAccount{},
&model.WalletLedger{},
&model.RenterGrowthLedger{},
&model.AuditLog{},
&model.ChatConversation{},
&model.ChatParticipant{},
+7
View File
@@ -22,6 +22,13 @@ type RentalOrder struct {
DepositOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
DepositWaivedAmountCent int64 `gorm:"not null;default:0" json:"-"`
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"`
RentOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
RentDiscountAmountCent int64 `gorm:"not null;default:0" json:"-"`
RenterGrowthLevel string `gorm:"size:32;not null;default:'normal'" json:"renter_growth_level"`
RenterGrowthLevelName string `gorm:"size:32;not null;default:'普通'" json:"renter_growth_level_name"`
RenterDiscountBps int `gorm:"not null;default:10000" json:"renter_discount_bps"`
GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"`
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
+22
View File
@@ -0,0 +1,22 @@
package model
import "time"
// RenterGrowthLedger 记录租客成长积分变动流水,便于排查订单完成后的积分发放。
type RenterGrowthLedger struct {
ID uint64 `gorm:"primaryKey" json:"id"`
UserID uint64 `gorm:"not null;index" json:"user_id"`
OrderID uint64 `gorm:"not null;default:0;index;uniqueIndex:uk_renter_growth_order_source,priority:1" json:"order_id"`
Points int64 `gorm:"not null;default:0" json:"points"`
BeforePoints int64 `gorm:"not null;default:0" json:"before_points"`
AfterPoints int64 `gorm:"not null;default:0" json:"after_points"`
BeforeLevel string `gorm:"size:32;not null;default:'normal'" json:"before_level"`
AfterLevel string `gorm:"size:32;not null;default:'normal'" json:"after_level"`
Source string `gorm:"size:32;not null;default:'order_completed';uniqueIndex:uk_renter_growth_order_source,priority:2" json:"source"`
Remark string `gorm:"size:255;not null;default:''" json:"remark"`
CreatedAt time.Time `json:"created_at"`
}
func (RenterGrowthLedger) TableName() string {
return "renter_growth_ledger"
}
+2
View File
@@ -12,6 +12,8 @@ type User struct {
RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"`
CreditScore int `gorm:"not null;default:100" json:"credit_score"`
DepositFreeQuotaCent int64 `gorm:"not null;default:0" json:"-"`
RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"`
RenterGrowthLevel string `gorm:"size:32;not null;default:'normal';index:idx_users_renter_growth_level,priority:1" json:"renter_growth_level"`
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
@@ -12,6 +12,9 @@ type UserDTO struct {
DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"`
DepositFreeUsedCent int64 `json:"deposit_free_used_cent"`
DepositFreeRemainingCent int64 `json:"deposit_free_remaining_cent"`
RenterGrowthPoints int64 `json:"renter_growth_points"`
RenterGrowthLevel string `json:"renter_growth_level"`
RenterGrowthLevelName string `json:"renter_growth_level_name"`
AvailableBalanceCent int64 `json:"available_balance_cent"`
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
Status string `json:"status"`
@@ -10,6 +10,7 @@ import (
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/internal/modules/wallet"
"hfb_sys/backend/pkg/crypto"
@@ -355,17 +356,17 @@ func (r *Repository) AdjustWallet(ctx context.Context, adminID uint64, userID ui
}
return appendAuditLog(tx, adminID, "admin_user.wallet_adjust", user.ID, meta, map[string]any{
"user_id": user.ID,
"direction": direction,
"amount_cent": amountCent,
"before_available_cent": beforeAvailable,
"after_available_cent": account.AvailableBalanceCent,
"before_frozen_cent": beforeFrozen,
"after_frozen_cent": account.FrozenBalanceCent,
"reason": reason,
"reference_no": referenceNo,
"biz_type": bizType,
"biz_no": bizNo,
"user_id": user.ID,
"direction": direction,
"amount_cent": amountCent,
"before_available_cent": beforeAvailable,
"after_available_cent": account.AvailableBalanceCent,
"before_frozen_cent": beforeFrozen,
"after_frozen_cent": account.FrozenBalanceCent,
"reason": reason,
"reference_no": referenceNo,
"biz_type": bizType,
"biz_no": bizNo,
})
})
if err != nil {
@@ -399,6 +400,9 @@ func (row userRow) toDTO() UserDTO {
DepositFreeQuotaCent: row.DepositFreeQuotaCent,
DepositFreeUsedCent: row.DepositFreeUsedCent,
DepositFreeRemainingCent: remaining,
RenterGrowthPoints: row.RenterGrowthPoints,
RenterGrowthLevel: effectiveRenterGrowthLevel(row.User),
RenterGrowthLevelName: renterGrowthLevelName(row.RenterGrowthLevel),
AvailableBalanceCent: row.AvailableBalanceCent,
FrozenBalanceCent: row.FrozenBalanceCent,
Status: row.Status,
@@ -411,6 +415,26 @@ func (row userRow) toDTO() UserDTO {
}
}
func effectiveRenterGrowthLevel(user model.User) string {
if user.RenterGrowthLevel != "" {
return user.RenterGrowthLevel
}
return rentergrowth.DefaultLevelCode
}
func renterGrowthLevelName(code string) string {
switch code {
case "platinum":
return "铂金"
case "diamond":
return "钻石"
case "peak":
return "巅峰"
default:
return rentergrowth.DefaultLevelName
}
}
func depositFreeQuotaAmountCent(req DepositFreeQuotaRequest) int64 {
return req.AmountCent
}
@@ -12,6 +12,7 @@ import (
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/adminnotification"
"hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/internal/modules/wallet"
"hfb_sys/backend/pkg/money"
@@ -110,6 +111,11 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
if err := tx.Save(&row).Error; err != nil {
return err
}
if order.Status == "completed" {
if err := rentergrowth.AwardOrderCompleted(tx, &order, arbitrationActualRentCent(order, settlement), rentergrowth.SourceOrderCompleted); err != nil {
return err
}
}
if err := tx.Save(&order).Error; err != nil {
return err
}
@@ -194,6 +200,21 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
return &dto, nil
}
func arbitrationActualRentCent(order model.RentalOrder, settlement arbitrationSettlement) int64 {
rentRefundCent := settlement.RenterRefundAmountCent - settlement.RenterDepositRefundCent
if rentRefundCent < 0 {
rentRefundCent = 0
}
if rentRefundCent > order.RentAmountCent {
rentRefundCent = order.RentAmountCent
}
actualRentCent := order.RentAmountCent - rentRefundCent
if actualRentCent < 0 {
return 0
}
return actualRentCent
}
func isPlatformManagedOrder(order model.RentalOrder) bool {
return order.SettlementMode == "platform_managed" || order.HandoffMode == "platform"
}
@@ -5,6 +5,7 @@ import (
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/internal/modules/wallet"
"gorm.io/gorm"
@@ -42,6 +43,9 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil {
return nil, err
}
if err := rentergrowth.AwardOrderCompleted(tx, order, settlement.ActualRentAmountCent, rentergrowth.SourceOrderCompleted); err != nil {
return nil, err
}
if err := saveFinalizedCheckout(tx, order, checkout, listing, account); err != nil {
return nil, err
}
+7
View File
@@ -32,6 +32,13 @@ type OrderDTO struct {
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
RentOriginalAmountCent int64 `json:"rent_original_amount_cent,omitempty"`
RentDiscountAmountCent int64 `json:"rent_discount_amount_cent,omitempty"`
RenterGrowthLevel string `json:"renter_growth_level,omitempty"`
RenterGrowthLevelName string `json:"renter_growth_level_name,omitempty"`
RenterDiscountBps int `json:"renter_discount_bps,omitempty"`
GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"`
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
Status string `json:"status"`
HandoffStatus string `json:"handoff_status"`
@@ -8,6 +8,7 @@ import (
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chat"
"hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/modules/rentergrowth"
"gorm.io/datatypes"
"gorm.io/gorm"
@@ -42,6 +43,16 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
}
rentHours := estimateOrderDurationHours(snapshot)
pricing := buildOrderPricing(listing, account)
rentOriginalAmountCent := pricing.RentAmountCent
growthSnapshot, err := rentergrowth.SnapshotForUser(tx, renterID)
if err != nil {
return err
}
rentDiscountAmountCent := rentergrowth.CalculateDiscountCent(rentOriginalAmountCent, pricing.PlatformFeeCent, growthSnapshot.DiscountBps)
if rentDiscountAmountCent > 0 {
pricing.PlatformFeeCent -= rentDiscountAmountCent
pricing.RentAmountCent -= rentDiscountAmountCent
}
depositOriginalAmountCent := listing.DepositAmountCent
paidDepositAmountCent, waivedDepositAmountCent, err := r.depositAmountsForOrder(tx, renterID, depositOriginalAmountCent)
if err != nil {
@@ -60,6 +71,11 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
DepositOriginalAmountCent: depositOriginalAmountCent,
DepositWaivedAmountCent: waivedDepositAmountCent,
PlatformFeeCent: pricing.PlatformFeeCent,
RentOriginalAmountCent: rentOriginalAmountCent,
RentDiscountAmountCent: rentDiscountAmountCent,
RenterGrowthLevel: growthSnapshot.LevelCode,
RenterGrowthLevelName: growthSnapshot.LevelName,
RenterDiscountBps: growthSnapshot.DiscountBps,
AccountSnapshot: snapshot,
Status: orderStatusPendingPayment,
HandoffStatus: handoffStatusNone,
@@ -82,6 +82,13 @@ func (row orderRow) toAdminDTO() OrderDTO {
DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder),
DepositWaivedAmountCent: row.DepositWaivedAmountCent,
PlatformFeeCent: &platformFeeCent,
RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder),
RentDiscountAmountCent: row.RentDiscountAmountCent,
RenterGrowthLevel: effectiveRenterGrowthLevel(row.RentalOrder),
RenterGrowthLevelName: effectiveRenterGrowthLevelName(row.RentalOrder),
RenterDiscountBps: effectiveRenterDiscountBps(row.RentalOrder),
GrowthPointsAwarded: row.GrowthPointsAwarded,
GrowthPointsAwardedAt: row.GrowthPointsAwardedAt,
AccountSnapshot: row.AccountSnapshot,
Status: row.Status,
HandoffStatus: row.HandoffStatus,
@@ -154,6 +161,34 @@ func effectiveDepositOriginalAmountCent(order model.RentalOrder) int64 {
return order.DepositAmountCent
}
func effectiveRentOriginalAmountCent(order model.RentalOrder) int64 {
if order.RentOriginalAmountCent > 0 {
return order.RentOriginalAmountCent
}
return order.RentAmountCent + order.RentDiscountAmountCent
}
func effectiveRenterGrowthLevel(order model.RentalOrder) string {
if order.RenterGrowthLevel != "" {
return order.RenterGrowthLevel
}
return "normal"
}
func effectiveRenterGrowthLevelName(order model.RentalOrder) string {
if order.RenterGrowthLevelName != "" {
return order.RenterGrowthLevelName
}
return "普通"
}
func effectiveRenterDiscountBps(order model.RentalOrder) int {
if order.RenterDiscountBps > 0 {
return order.RenterDiscountBps
}
return 10000
}
func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO {
rentAmountCent := checkout.RentAmountCent
ownerRentAmountCent := checkout.OwnerRentAmountCent
@@ -275,6 +310,13 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64)
dto.DisplayAmountCent = ownerAmountCent
dto.RentAmountCent = nil
dto.OwnerRentAmountCent = &ownerAmountCent
dto.RentOriginalAmountCent = 0
dto.RentDiscountAmountCent = 0
dto.RenterGrowthLevel = ""
dto.RenterGrowthLevelName = ""
dto.RenterDiscountBps = 0
dto.GrowthPointsAwarded = 0
dto.GrowthPointsAwardedAt = nil
sanitizeOrderSnapshot(&dto.AccountSnapshot, "owner")
case userID == order.RenterID:
rentAmountCent := order.RentAmountCent
@@ -282,12 +324,24 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64)
dto.DisplayAmountCent = rentAmountCent
dto.RentAmountCent = &rentAmountCent
dto.OwnerRentAmountCent = nil
dto.RentOriginalAmountCent = effectiveRentOriginalAmountCent(order)
dto.RentDiscountAmountCent = order.RentDiscountAmountCent
dto.RenterGrowthLevel = effectiveRenterGrowthLevel(order)
dto.RenterGrowthLevelName = effectiveRenterGrowthLevelName(order)
dto.RenterDiscountBps = effectiveRenterDiscountBps(order)
sanitizeOrderSnapshot(&dto.AccountSnapshot, "renter")
default:
dto.PriceRole = ""
dto.DisplayAmountCent = 0
dto.RentAmountCent = nil
dto.OwnerRentAmountCent = nil
dto.RentOriginalAmountCent = 0
dto.RentDiscountAmountCent = 0
dto.RenterGrowthLevel = ""
dto.RenterGrowthLevelName = ""
dto.RenterDiscountBps = 0
dto.GrowthPointsAwarded = 0
dto.GrowthPointsAwardedAt = nil
sanitizeOrderSnapshot(&dto.AccountSnapshot, "")
}
}
@@ -140,6 +140,65 @@ func TestRepositoryCreateOrderRejectsOwnListing(t *testing.T) {
}
}
func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
db := setupOrderTestDB(t)
repo := NewRepository(db)
owner := model.User{Phone: "13800001101"}
renter := model.User{
Phone: "13800001102",
RenterGrowthPoints: 300,
RenterGrowthLevel: "platinum",
}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("create owner failed: %v", err)
}
if err := db.Create(&renter).Error; err != nil {
t.Fatalf("create renter failed: %v", err)
}
account := model.GameAccount{
OwnerID: owner.ID,
ServerRegion: "国服",
LoginPlatform: "steam",
Title: "测试账号",
AssetSummary: datatypes.JSON([]byte(`{"price_breakdown":{"seller_total_price":90,"platform_markup_amount":10}}`)),
}
if err := db.Create(&account).Error; err != nil {
t.Fatalf("create account failed: %v", err)
}
listing := model.RentalListing{
AccountID: account.ID,
OwnerID: owner.ID,
PriceCent: 10000,
Status: listingStatusPublished,
ReviewStatus: listingReviewStatusApproved,
}
if err := db.Create(&listing).Error; err != nil {
t.Fatalf("create listing failed: %v", err)
}
dto, err := repo.Create(t.Context(), renter.ID, CreateRequest{ListingID: listing.ID})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if dto.RentAmountCent == nil || *dto.RentAmountCent != 9900 {
t.Fatalf("rent amount = %v, want 9900", dto.RentAmountCent)
}
if dto.RentOriginalAmountCent != 10000 || dto.RentDiscountAmountCent != 100 {
t.Fatalf("discount snapshot = original %d discount %d, want 10000/100", dto.RentOriginalAmountCent, dto.RentDiscountAmountCent)
}
if dto.RenterGrowthLevel != "platinum" || dto.RenterGrowthLevelName != "铂金" || dto.RenterDiscountBps != 9900 {
t.Fatalf("growth snapshot = %s/%s/%d, want platinum/铂金/9900", dto.RenterGrowthLevel, dto.RenterGrowthLevelName, dto.RenterDiscountBps)
}
var saved model.RentalOrder
if err := db.First(&saved, dto.ID).Error; err != nil {
t.Fatalf("find saved order failed: %v", err)
}
if saved.OwnerRentAmountCent != 9000 || saved.PlatformFeeCent != 900 {
t.Fatalf("pricing = owner %d platform %d, want 9000/900", saved.OwnerRentAmountCent, saved.PlatformFeeCent)
}
}
// TestBuildOrderPricing 测试订单定价计算
func TestBuildOrderPricingBasic(t *testing.T) {
listing := model.RentalListing{
@@ -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)
}
}
@@ -8,6 +8,7 @@ import (
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/rentergrowth"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -36,6 +37,7 @@ var defaultConfigs = []defaultConfig{
{Key: listingPublishAgreementsConfigKey, Value: defaultListingPublishAgreementsConfigValue(), Description: "发布账号前协议配置 JSON"},
{Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"},
{Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), Description: "租后须知配置 JSON"},
{Key: rentergrowth.ConfigKey, Value: rentergrowth.DefaultConfigValue(), Description: "租客成长等级规则 JSON(积分门槛/下单折扣)"},
{Key: "chat.default_support_admin_id", Value: "2", Description: "默认客服 ID(必须是启用状态的客服角色)"},
{Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"},
{Key: "chat.listing_group_welcome", Value: "欢迎加入账号群!请号主扫描下方二维码加入企业微信群,方便客服与您及时联系。", Description: "发布群创建后自动发送的欢迎语"},
@@ -73,6 +75,7 @@ var adminVisibleConfigKeys = []string{
"order.pending_payment_timeout_minutes",
"order.return_overdue_grace_minutes",
"profile.post_rental_notice",
"renter.growth_level_rules",
}
func NewRepository(db *gorm.DB) *Repository {
@@ -0,0 +1,69 @@
-- +goose Up
ALTER TABLE users
ADD COLUMN renter_growth_points BIGINT NOT NULL DEFAULT 0 COMMENT '租客成长积分' AFTER deposit_free_quota_cent,
ADD COLUMN renter_growth_level VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '租客成长等级: normal普通/platinum铂金/diamond钻石/peak巅峰' AFTER renter_growth_points,
ADD KEY idx_users_renter_growth_level (renter_growth_level, renter_growth_points);
ALTER TABLE rental_orders
ADD COLUMN rent_original_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '租客下单原始租金(分)' AFTER platform_fee_cent,
ADD COLUMN rent_discount_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '租客成长等级优惠金额(分)' AFTER rent_original_amount_cent,
ADD COLUMN renter_growth_level VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '下单时租客成长等级快照' AFTER rent_discount_amount_cent,
ADD COLUMN renter_growth_level_name VARCHAR(32) NOT NULL DEFAULT '普通' COMMENT '下单时租客成长等级名称快照' AFTER renter_growth_level,
ADD COLUMN renter_discount_bps INT NOT NULL DEFAULT 10000 COMMENT '下单时租客等级折扣,10000=无折扣' AFTER renter_growth_level_name,
ADD COLUMN growth_points_awarded BIGINT NOT NULL DEFAULT 0 COMMENT '订单完成后已发放成长积分' AFTER renter_discount_bps,
ADD COLUMN growth_points_awarded_at DATETIME NULL COMMENT '成长积分发放时间' AFTER growth_points_awarded,
ADD KEY idx_rental_orders_growth_awarded (growth_points_awarded_at, renter_id);
UPDATE rental_orders
SET rent_original_amount_cent = rent_amount_cent
WHERE rent_original_amount_cent = 0;
CREATE TABLE renter_growth_ledger (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
order_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租号订单ID',
points BIGINT NOT NULL DEFAULT 0 COMMENT '变动积分',
before_points BIGINT NOT NULL DEFAULT 0 COMMENT '变动前积分',
after_points BIGINT NOT NULL DEFAULT 0 COMMENT '变动后积分',
before_level VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '变动前等级',
after_level VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '变动后等级',
source VARCHAR(32) NOT NULL DEFAULT 'order_completed' COMMENT '来源',
remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_renter_growth_order_source (order_id, source),
KEY idx_renter_growth_ledger_user (user_id),
KEY idx_renter_growth_ledger_order (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租客成长积分流水';
INSERT INTO system_configs (`key`, `value`, `description`, `created_at`, `updated_at`)
VALUES (
'renter.growth_level_rules',
'{"enabled":true,"points_per_yuan":1,"levels":[{"code":"normal","name":"普通","min_points":0,"discount_bps":10000},{"code":"platinum","name":"铂金","min_points":300,"discount_bps":9900},{"code":"diamond","name":"钻石","min_points":1000,"discount_bps":9800},{"code":"peak","name":"巅峰","min_points":5000,"discount_bps":9500}]}',
'租客成长等级规则 JSON(积分门槛/下单折扣)',
NOW(),
NOW()
)
ON DUPLICATE KEY UPDATE description = VALUES(description);
-- +goose Down
DELETE FROM system_configs WHERE `key` = 'renter.growth_level_rules';
DROP TABLE renter_growth_ledger;
ALTER TABLE rental_orders
DROP KEY idx_rental_orders_growth_awarded,
DROP COLUMN growth_points_awarded_at,
DROP COLUMN growth_points_awarded,
DROP COLUMN renter_discount_bps,
DROP COLUMN renter_growth_level_name,
DROP COLUMN renter_growth_level,
DROP COLUMN rent_discount_amount_cent,
DROP COLUMN rent_original_amount_cent;
ALTER TABLE users
DROP KEY idx_users_renter_growth_level,
DROP COLUMN renter_growth_level,
DROP COLUMN renter_growth_points;