From 321f0781cf4a6be75cc86fac39bdb1bb74a69dc9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 26 Jul 2026 11:25:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=A7=9F=E5=AE=A2=E6=88=90?= =?UTF-8?q?=E9=95=BF=E7=AD=89=E7=BA=A7=E5=B9=B6=E4=BC=98=E5=8C=96=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/database/test_helper.go | 2 + backend/internal/model/order.go | 7 + backend/internal/model/renter_growth.go | 22 ++ backend/internal/model/user.go | 2 + backend/internal/modules/adminuser/dto.go | 3 + .../internal/modules/adminuser/repository.go | 46 ++- .../internal/modules/dispute/arbitration.go | 21 ++ .../modules/order/checkout_finalize.go | 4 + backend/internal/modules/order/dto.go | 7 + backend/internal/modules/order/lifecycle.go | 16 + backend/internal/modules/order/presenter.go | 54 ++++ .../order/repository_integration_test.go | 59 ++++ .../internal/modules/rentergrowth/growth.go | 282 ++++++++++++++++++ .../modules/rentergrowth/growth_test.go | 102 +++++++ .../modules/systemconfig/repository.go | 3 + .../migrations/000037_renter_growth_level.sql | 69 +++++ frontend/src/features/admin/api/adminUsers.ts | 3 + .../admin/views/AdminOrderDetailView.vue | 40 ++- .../features/admin/views/AdminUsersView.vue | 8 + frontend/src/features/auth/api/auth.ts | 2 + .../features/auth/views/MobileProfileView.vue | 53 +++- .../src/features/auth/views/ProfileView.vue | 16 +- frontend/src/features/orders/api/orders.ts | 7 + .../orders/views/MobileOrderDetailView.vue | 8 + .../features/orders/views/OrderDetailView.vue | 8 + frontend/src/layouts/AppLayout.vue | 99 ++++++ frontend/src/stores/session.ts | 21 ++ 27 files changed, 948 insertions(+), 16 deletions(-) create mode 100644 backend/internal/model/renter_growth.go create mode 100644 backend/internal/modules/rentergrowth/growth.go create mode 100644 backend/internal/modules/rentergrowth/growth_test.go create mode 100644 backend/migrations/000037_renter_growth_level.sql diff --git a/backend/internal/database/test_helper.go b/backend/internal/database/test_helper.go index 0f2394b..c36a41f 100644 --- a/backend/internal/database/test_helper.go +++ b/backend/internal/database/test_helper.go @@ -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{}, diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index 234390a..a094f88 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -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"` diff --git a/backend/internal/model/renter_growth.go b/backend/internal/model/renter_growth.go new file mode 100644 index 0000000..f0ecd4b --- /dev/null +++ b/backend/internal/model/renter_growth.go @@ -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" +} diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index a2a4b16..96d9407 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -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"` diff --git a/backend/internal/modules/adminuser/dto.go b/backend/internal/modules/adminuser/dto.go index b87ec84..be6822d 100644 --- a/backend/internal/modules/adminuser/dto.go +++ b/backend/internal/modules/adminuser/dto.go @@ -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"` diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index 0e15e84..140c97f 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -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 } diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index febb9f9..a00a319 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -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" } diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index 02fecdc..4056d33 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -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 } diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index f946bca..09cce1e 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -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"` diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 0034001..48c65e0 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -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, diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index bde5392..c62ab62 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -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, "") } } diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 564eb0e..c348c79 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -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{ diff --git a/backend/internal/modules/rentergrowth/growth.go b/backend/internal/modules/rentergrowth/growth.go new file mode 100644 index 0000000..14c2edf --- /dev/null +++ b/backend/internal/modules/rentergrowth/growth.go @@ -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") +} diff --git a/backend/internal/modules/rentergrowth/growth_test.go b/backend/internal/modules/rentergrowth/growth_test.go new file mode 100644 index 0000000..ef1f02a --- /dev/null +++ b/backend/internal/modules/rentergrowth/growth_test.go @@ -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) + } +} diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index afd663d..4d0ed7d 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -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 { diff --git a/backend/migrations/000037_renter_growth_level.sql b/backend/migrations/000037_renter_growth_level.sql new file mode 100644 index 0000000..2322dc7 --- /dev/null +++ b/backend/migrations/000037_renter_growth_level.sql @@ -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; diff --git a/frontend/src/features/admin/api/adminUsers.ts b/frontend/src/features/admin/api/adminUsers.ts index 3cba6a5..a031cec 100644 --- a/frontend/src/features/admin/api/adminUsers.ts +++ b/frontend/src/features/admin/api/adminUsers.ts @@ -13,6 +13,9 @@ export interface AdminUserItem { deposit_free_quota_cent: number deposit_free_used_cent: number deposit_free_remaining_cent: number + renter_growth_points: number + renter_growth_level: string + renter_growth_level_name: string available_balance_cent: number frozen_balance_cent: number status: UserStatus diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index c50f111..337a591 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -266,7 +266,17 @@ const ownerLossPriceCent = computed(() => { const hasOwnerRentBreakdown = computed(() => ownerCoinBasePriceCent.value !== null) const fundSplitRows = computed(() => { if (!order.value) return [] - const rows: FundSplitRow[] = [{ label: '租客租金', amountCent: order.value.rent_amount_cent }] + const rows: FundSplitRow[] = [] + if (Number(order.value.rent_discount_amount_cent || 0) > 0) { + rows.push( + { label: '原始租金', amountCent: order.value.rent_original_amount_cent }, + { + label: `${order.value.renter_growth_level_name || '成长等级'}优惠`, + amountCent: -Number(order.value.rent_discount_amount_cent || 0), + } + ) + } + rows.push({ label: '租客租金', amountCent: order.value.rent_amount_cent }) if (hasOwnerRentBreakdown.value) { rows.push( { label: '号主纯币价格', amountCent: ownerCoinBasePriceCent.value }, @@ -843,7 +853,12 @@ function paymentPaidAt(record: AdminPayment) { {{ resetActionLabel }} - + {{ platformHandoffAction?.label || '客服代交接' }} + + + + + +