实现租客成长等级并优化展示
This commit is contained in:
@@ -51,6 +51,7 @@ func MigrateRentalTransactionTestSchema(db *gorm.DB) error {
|
|||||||
}
|
}
|
||||||
return db.AutoMigrate(
|
return db.AutoMigrate(
|
||||||
&model.RentalOrder{},
|
&model.RentalOrder{},
|
||||||
|
&model.SystemConfig{},
|
||||||
&model.PaymentOrder{},
|
&model.PaymentOrder{},
|
||||||
&model.Notification{},
|
&model.Notification{},
|
||||||
&model.AdminNotification{},
|
&model.AdminNotification{},
|
||||||
@@ -58,6 +59,7 @@ func MigrateRentalTransactionTestSchema(db *gorm.DB) error {
|
|||||||
&model.OrderCheckout{},
|
&model.OrderCheckout{},
|
||||||
&model.WalletAccount{},
|
&model.WalletAccount{},
|
||||||
&model.WalletLedger{},
|
&model.WalletLedger{},
|
||||||
|
&model.RenterGrowthLedger{},
|
||||||
&model.AuditLog{},
|
&model.AuditLog{},
|
||||||
&model.ChatConversation{},
|
&model.ChatConversation{},
|
||||||
&model.ChatParticipant{},
|
&model.ChatParticipant{},
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ type RentalOrder struct {
|
|||||||
DepositOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
DepositOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
DepositWaivedAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
DepositWaivedAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
PlatformFeeCent 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"`
|
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||||
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
|
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
|
||||||
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
|
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ type User struct {
|
|||||||
RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"`
|
RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"`
|
||||||
CreditScore int `gorm:"not null;default:100" json:"credit_score"`
|
CreditScore int `gorm:"not null;default:100" json:"credit_score"`
|
||||||
DepositFreeQuotaCent int64 `gorm:"not null;default:0" json:"-"`
|
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"`
|
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
||||||
LastLoginAt *time.Time `json:"last_login_at"`
|
LastLoginAt *time.Time `json:"last_login_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ type UserDTO struct {
|
|||||||
DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"`
|
DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"`
|
||||||
DepositFreeUsedCent int64 `json:"deposit_free_used_cent"`
|
DepositFreeUsedCent int64 `json:"deposit_free_used_cent"`
|
||||||
DepositFreeRemainingCent int64 `json:"deposit_free_remaining_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"`
|
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
||||||
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
|
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"hfb_sys/backend/internal/auditlog"
|
"hfb_sys/backend/internal/auditlog"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||||
"hfb_sys/backend/internal/modules/wallet"
|
"hfb_sys/backend/internal/modules/wallet"
|
||||||
"hfb_sys/backend/pkg/crypto"
|
"hfb_sys/backend/pkg/crypto"
|
||||||
|
|
||||||
@@ -399,6 +400,9 @@ func (row userRow) toDTO() UserDTO {
|
|||||||
DepositFreeQuotaCent: row.DepositFreeQuotaCent,
|
DepositFreeQuotaCent: row.DepositFreeQuotaCent,
|
||||||
DepositFreeUsedCent: row.DepositFreeUsedCent,
|
DepositFreeUsedCent: row.DepositFreeUsedCent,
|
||||||
DepositFreeRemainingCent: remaining,
|
DepositFreeRemainingCent: remaining,
|
||||||
|
RenterGrowthPoints: row.RenterGrowthPoints,
|
||||||
|
RenterGrowthLevel: effectiveRenterGrowthLevel(row.User),
|
||||||
|
RenterGrowthLevelName: renterGrowthLevelName(row.RenterGrowthLevel),
|
||||||
AvailableBalanceCent: row.AvailableBalanceCent,
|
AvailableBalanceCent: row.AvailableBalanceCent,
|
||||||
FrozenBalanceCent: row.FrozenBalanceCent,
|
FrozenBalanceCent: row.FrozenBalanceCent,
|
||||||
Status: row.Status,
|
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 {
|
func depositFreeQuotaAmountCent(req DepositFreeQuotaRequest) int64 {
|
||||||
return req.AmountCent
|
return req.AmountCent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
"hfb_sys/backend/internal/modules/adminnotification"
|
"hfb_sys/backend/internal/modules/adminnotification"
|
||||||
"hfb_sys/backend/internal/modules/notification"
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
|
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||||
"hfb_sys/backend/internal/modules/wallet"
|
"hfb_sys/backend/internal/modules/wallet"
|
||||||
"hfb_sys/backend/pkg/money"
|
"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 {
|
if err := tx.Save(&row).Error; err != nil {
|
||||||
return err
|
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 {
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -194,6 +200,21 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
|
|||||||
return &dto, nil
|
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 {
|
func isPlatformManagedOrder(order model.RentalOrder) bool {
|
||||||
return order.SettlementMode == "platform_managed" || order.HandoffMode == "platform"
|
return order.SettlementMode == "platform_managed" || order.HandoffMode == "platform"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
"hfb_sys/backend/internal/modules/notification"
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
|
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||||
"hfb_sys/backend/internal/modules/wallet"
|
"hfb_sys/backend/internal/modules/wallet"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"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 {
|
if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil {
|
||||||
return nil, err
|
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 {
|
if err := saveFinalizedCheckout(tx, order, checkout, listing, account); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ type OrderDTO struct {
|
|||||||
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
|
DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"`
|
||||||
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
|
DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"`
|
||||||
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
|
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"`
|
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
HandoffStatus string `json:"handoff_status"`
|
HandoffStatus string `json:"handoff_status"`
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
"hfb_sys/backend/internal/modules/chat"
|
"hfb_sys/backend/internal/modules/chat"
|
||||||
"hfb_sys/backend/internal/modules/notification"
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
|
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -42,6 +43,16 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
|||||||
}
|
}
|
||||||
rentHours := estimateOrderDurationHours(snapshot)
|
rentHours := estimateOrderDurationHours(snapshot)
|
||||||
pricing := buildOrderPricing(listing, account)
|
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
|
depositOriginalAmountCent := listing.DepositAmountCent
|
||||||
paidDepositAmountCent, waivedDepositAmountCent, err := r.depositAmountsForOrder(tx, renterID, depositOriginalAmountCent)
|
paidDepositAmountCent, waivedDepositAmountCent, err := r.depositAmountsForOrder(tx, renterID, depositOriginalAmountCent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -60,6 +71,11 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
|||||||
DepositOriginalAmountCent: depositOriginalAmountCent,
|
DepositOriginalAmountCent: depositOriginalAmountCent,
|
||||||
DepositWaivedAmountCent: waivedDepositAmountCent,
|
DepositWaivedAmountCent: waivedDepositAmountCent,
|
||||||
PlatformFeeCent: pricing.PlatformFeeCent,
|
PlatformFeeCent: pricing.PlatformFeeCent,
|
||||||
|
RentOriginalAmountCent: rentOriginalAmountCent,
|
||||||
|
RentDiscountAmountCent: rentDiscountAmountCent,
|
||||||
|
RenterGrowthLevel: growthSnapshot.LevelCode,
|
||||||
|
RenterGrowthLevelName: growthSnapshot.LevelName,
|
||||||
|
RenterDiscountBps: growthSnapshot.DiscountBps,
|
||||||
AccountSnapshot: snapshot,
|
AccountSnapshot: snapshot,
|
||||||
Status: orderStatusPendingPayment,
|
Status: orderStatusPendingPayment,
|
||||||
HandoffStatus: handoffStatusNone,
|
HandoffStatus: handoffStatusNone,
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
|||||||
DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder),
|
DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder),
|
||||||
DepositWaivedAmountCent: row.DepositWaivedAmountCent,
|
DepositWaivedAmountCent: row.DepositWaivedAmountCent,
|
||||||
PlatformFeeCent: &platformFeeCent,
|
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,
|
AccountSnapshot: row.AccountSnapshot,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
HandoffStatus: row.HandoffStatus,
|
HandoffStatus: row.HandoffStatus,
|
||||||
@@ -154,6 +161,34 @@ func effectiveDepositOriginalAmountCent(order model.RentalOrder) int64 {
|
|||||||
return order.DepositAmountCent
|
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 {
|
func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO {
|
||||||
rentAmountCent := checkout.RentAmountCent
|
rentAmountCent := checkout.RentAmountCent
|
||||||
ownerRentAmountCent := checkout.OwnerRentAmountCent
|
ownerRentAmountCent := checkout.OwnerRentAmountCent
|
||||||
@@ -275,6 +310,13 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64)
|
|||||||
dto.DisplayAmountCent = ownerAmountCent
|
dto.DisplayAmountCent = ownerAmountCent
|
||||||
dto.RentAmountCent = nil
|
dto.RentAmountCent = nil
|
||||||
dto.OwnerRentAmountCent = &ownerAmountCent
|
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")
|
sanitizeOrderSnapshot(&dto.AccountSnapshot, "owner")
|
||||||
case userID == order.RenterID:
|
case userID == order.RenterID:
|
||||||
rentAmountCent := order.RentAmountCent
|
rentAmountCent := order.RentAmountCent
|
||||||
@@ -282,12 +324,24 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64)
|
|||||||
dto.DisplayAmountCent = rentAmountCent
|
dto.DisplayAmountCent = rentAmountCent
|
||||||
dto.RentAmountCent = &rentAmountCent
|
dto.RentAmountCent = &rentAmountCent
|
||||||
dto.OwnerRentAmountCent = nil
|
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")
|
sanitizeOrderSnapshot(&dto.AccountSnapshot, "renter")
|
||||||
default:
|
default:
|
||||||
dto.PriceRole = ""
|
dto.PriceRole = ""
|
||||||
dto.DisplayAmountCent = 0
|
dto.DisplayAmountCent = 0
|
||||||
dto.RentAmountCent = nil
|
dto.RentAmountCent = nil
|
||||||
dto.OwnerRentAmountCent = 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, "")
|
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 测试订单定价计算
|
// TestBuildOrderPricing 测试订单定价计算
|
||||||
func TestBuildOrderPricingBasic(t *testing.T) {
|
func TestBuildOrderPricingBasic(t *testing.T) {
|
||||||
listing := model.RentalListing{
|
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/auditlog"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -36,6 +37,7 @@ var defaultConfigs = []defaultConfig{
|
|||||||
{Key: listingPublishAgreementsConfigKey, Value: defaultListingPublishAgreementsConfigValue(), Description: "发布账号前协议配置 JSON"},
|
{Key: listingPublishAgreementsConfigKey, Value: defaultListingPublishAgreementsConfigValue(), Description: "发布账号前协议配置 JSON"},
|
||||||
{Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"},
|
{Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"},
|
||||||
{Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), 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.default_support_admin_id", Value: "2", Description: "默认客服 ID(必须是启用状态的客服角色)"},
|
||||||
{Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"},
|
{Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"},
|
||||||
{Key: "chat.listing_group_welcome", Value: "欢迎加入账号群!请号主扫描下方二维码加入企业微信群,方便客服与您及时联系。", Description: "发布群创建后自动发送的欢迎语"},
|
{Key: "chat.listing_group_welcome", Value: "欢迎加入账号群!请号主扫描下方二维码加入企业微信群,方便客服与您及时联系。", Description: "发布群创建后自动发送的欢迎语"},
|
||||||
@@ -73,6 +75,7 @@ var adminVisibleConfigKeys = []string{
|
|||||||
"order.pending_payment_timeout_minutes",
|
"order.pending_payment_timeout_minutes",
|
||||||
"order.return_overdue_grace_minutes",
|
"order.return_overdue_grace_minutes",
|
||||||
"profile.post_rental_notice",
|
"profile.post_rental_notice",
|
||||||
|
"renter.growth_level_rules",
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRepository(db *gorm.DB) *Repository {
|
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;
|
||||||
@@ -13,6 +13,9 @@ export interface AdminUserItem {
|
|||||||
deposit_free_quota_cent: number
|
deposit_free_quota_cent: number
|
||||||
deposit_free_used_cent: number
|
deposit_free_used_cent: number
|
||||||
deposit_free_remaining_cent: number
|
deposit_free_remaining_cent: number
|
||||||
|
renter_growth_points: number
|
||||||
|
renter_growth_level: string
|
||||||
|
renter_growth_level_name: string
|
||||||
available_balance_cent: number
|
available_balance_cent: number
|
||||||
frozen_balance_cent: number
|
frozen_balance_cent: number
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
|
|||||||
@@ -266,7 +266,17 @@ const ownerLossPriceCent = computed(() => {
|
|||||||
const hasOwnerRentBreakdown = computed(() => ownerCoinBasePriceCent.value !== null)
|
const hasOwnerRentBreakdown = computed(() => ownerCoinBasePriceCent.value !== null)
|
||||||
const fundSplitRows = computed(() => {
|
const fundSplitRows = computed(() => {
|
||||||
if (!order.value) return []
|
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) {
|
if (hasOwnerRentBreakdown.value) {
|
||||||
rows.push(
|
rows.push(
|
||||||
{ label: '号主纯币价格', amountCent: ownerCoinBasePriceCent.value },
|
{ label: '号主纯币价格', amountCent: ownerCoinBasePriceCent.value },
|
||||||
@@ -843,7 +853,12 @@ function paymentPaidAt(record: AdminPayment) {
|
|||||||
<el-button v-if="canResetHandoff" type="success" @click="openAction('reset')">
|
<el-button v-if="canResetHandoff" type="success" @click="openAction('reset')">
|
||||||
{{ resetActionLabel }}
|
{{ resetActionLabel }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="canPlatformHandoff" type="primary" plain @click="openPlatformHandoff">
|
<el-button
|
||||||
|
v-if="canPlatformHandoff"
|
||||||
|
class="platform-handoff-button"
|
||||||
|
type="primary"
|
||||||
|
@click="openPlatformHandoff"
|
||||||
|
>
|
||||||
{{ platformHandoffAction?.label || '客服代交接' }}
|
{{ platformHandoffAction?.label || '客服代交接' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
@@ -1551,6 +1566,27 @@ function paymentPaidAt(record: AdminPayment) {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.platform-handoff-button {
|
||||||
|
min-width: 96px;
|
||||||
|
border-color: #2563eb;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 6px 14px rgba(37, 99, 235, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-handoff-button:hover,
|
||||||
|
.platform-handoff-button:focus {
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-handoff-button:active {
|
||||||
|
border-color: #1e40af;
|
||||||
|
background: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
.order-detail-grid {
|
.order-detail-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr);
|
grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr);
|
||||||
|
|||||||
@@ -312,6 +312,14 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
<el-table-column label="实名状态" width="100">
|
<el-table-column label="实名状态" width="100">
|
||||||
<template #default="{ row }">{{ realnameStatusLabel(row.realname_status) }}</template>
|
<template #default="{ row }">{{ realnameStatusLabel(row.realname_status) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="成长等级" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag type="success" effect="light">{{ row.renter_growth_level_name || '普通' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="成长积分" width="100" align="right">
|
||||||
|
<template #default="{ row }">{{ Number(row.renter_growth_points || 0) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="可用余额" width="108" align="right">
|
<el-table-column label="可用余额" width="108" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="balance-available">¥{{ moneyCent(row.available_balance_cent) }}</span>
|
<span class="balance-available">¥{{ moneyCent(row.available_balance_cent) }}</span>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface AuthUser {
|
|||||||
realname_status: RealnameStatusValue
|
realname_status: RealnameStatusValue
|
||||||
risk_status: RiskStatus
|
risk_status: RiskStatus
|
||||||
credit_score: number
|
credit_score: number
|
||||||
|
renter_growth_points?: number
|
||||||
|
renter_growth_level?: string
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,11 +104,16 @@ const settingsGroups = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
if (!isLoggedIn.value) {
|
if (!isLoggedIn.value) {
|
||||||
router.replace({ path: '/m/login', query: { redirect: route.fullPath } })
|
router.replace({ path: '/m/login', query: { redirect: route.fullPath } })
|
||||||
} else {
|
} else {
|
||||||
loadBalance()
|
try {
|
||||||
|
await session.loadMe()
|
||||||
|
} catch {
|
||||||
|
// 保留本地会话展示,避免个人中心因网络短暂异常整页不可用。
|
||||||
|
}
|
||||||
|
void loadBalance()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -348,6 +353,17 @@ function resolveAvatarURL(url: string | undefined | null) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="growth-card">
|
||||||
|
<div>
|
||||||
|
<span>租客成长等级</span>
|
||||||
|
<strong>{{ session.renterGrowthLevelName }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>当前积分</span>
|
||||||
|
<strong>{{ session.renterGrowthPoints }}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 余额卡片 - 银行卡质感暗色卡片 -->
|
<!-- 余额卡片 - 银行卡质感暗色卡片 -->
|
||||||
<div class="balance-card">
|
<div class="balance-card">
|
||||||
<div class="balance-row">
|
<div class="balance-row">
|
||||||
@@ -780,6 +796,39 @@ function resolveAvatarURL(url: string | undefined | null) {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.growth-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 12px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.growth-card > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba(243, 244, 246, 0.9);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 16px rgba(0, 0, 0, 0.02),
|
||||||
|
0 1px 4px rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.growth-card span {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.growth-card strong {
|
||||||
|
color: #7c3aed;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
/* ========== Balance Card ========== */
|
/* ========== Balance Card ========== */
|
||||||
.balance-card {
|
.balance-card {
|
||||||
margin: 12px 16px 14px;
|
margin: 12px 16px 14px;
|
||||||
|
|||||||
@@ -50,8 +50,10 @@ const realnameTone = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!session.phone) {
|
try {
|
||||||
await session.loadMe()
|
await session.loadMe()
|
||||||
|
} catch {
|
||||||
|
// 保留本地会话展示,避免资料页因网络短暂异常整页不可用。
|
||||||
}
|
}
|
||||||
resetForm()
|
resetForm()
|
||||||
})
|
})
|
||||||
@@ -258,6 +260,14 @@ async function savePassword() {
|
|||||||
<dt>实名状态</dt>
|
<dt>实名状态</dt>
|
||||||
<dd :class="`is-${realnameTone}`">{{ realnameStatusLabel(session.realnameStatus) }}</dd>
|
<dd :class="`is-${realnameTone}`">{{ realnameStatusLabel(session.realnameStatus) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>租客等级</dt>
|
||||||
|
<dd class="growth-level">{{ session.renterGrowthLevelName }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>成长积分</dt>
|
||||||
|
<dd>{{ session.renterGrowthPoints }}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<button class="realname-shortcut" type="button" @click="router.push('/realname')">
|
<button class="realname-shortcut" type="button" @click="router.push('/realname')">
|
||||||
<span>查看实名认证</span>
|
<span>查看实名认证</span>
|
||||||
@@ -604,6 +614,10 @@ async function savePassword() {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-card dd.growth-level {
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
.realname-shortcut {
|
.realname-shortcut {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ export interface Order {
|
|||||||
deposit_original_amount_cent: number
|
deposit_original_amount_cent: number
|
||||||
deposit_waived_amount_cent: number
|
deposit_waived_amount_cent: number
|
||||||
platform_fee_cent?: number
|
platform_fee_cent?: number
|
||||||
|
rent_original_amount_cent?: number
|
||||||
|
rent_discount_amount_cent?: number
|
||||||
|
renter_growth_level?: string
|
||||||
|
renter_growth_level_name?: string
|
||||||
|
renter_discount_bps?: number
|
||||||
|
growth_points_awarded?: number
|
||||||
|
growth_points_awarded_at?: string
|
||||||
account_snapshot?: Record<string, unknown>
|
account_snapshot?: Record<string, unknown>
|
||||||
listing_snapshot?: string
|
listing_snapshot?: string
|
||||||
checkout_info?: string
|
checkout_info?: string
|
||||||
|
|||||||
@@ -188,6 +188,14 @@ async function copyListingCode() {
|
|||||||
<div class="meta-item">
|
<div class="meta-item">
|
||||||
<span class="meta-label">{{ orderAmountLabel }}</span>
|
<span class="meta-label">{{ orderAmountLabel }}</span>
|
||||||
<strong class="meta-value accent">¥{{ money(orderRentDisplayAmount) }}</strong>
|
<strong class="meta-value accent">¥{{ money(orderRentDisplayAmount) }}</strong>
|
||||||
|
<span
|
||||||
|
v-if="isRenter && Number(order.rent_discount_amount_cent || 0) > 0"
|
||||||
|
class="meta-note"
|
||||||
|
>
|
||||||
|
{{ order.renter_growth_level_name || '成长等级' }}优惠 ¥{{
|
||||||
|
money(amountYuan(order.rent_discount_amount_cent))
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isOwner && ownerIncomeDisplayAmount !== null" class="meta-item">
|
<div v-if="isOwner && ownerIncomeDisplayAmount !== null" class="meta-item">
|
||||||
<span class="meta-label">{{ ownerIncomeLabel }}</span>
|
<span class="meta-label">{{ ownerIncomeLabel }}</span>
|
||||||
|
|||||||
@@ -388,6 +388,14 @@ watch([() => route.query.focus, order, loading], () => {
|
|||||||
<div class="metric-card highlight">
|
<div class="metric-card highlight">
|
||||||
<span class="metric-label">{{ orderAmountLabel }}</span>
|
<span class="metric-label">{{ orderAmountLabel }}</span>
|
||||||
<strong class="metric-value amount">¥{{ money(orderRentDisplayAmount) }}</strong>
|
<strong class="metric-value amount">¥{{ money(orderRentDisplayAmount) }}</strong>
|
||||||
|
<span
|
||||||
|
v-if="isRenter && Number(order.rent_discount_amount_cent || 0) > 0"
|
||||||
|
class="metric-note"
|
||||||
|
>
|
||||||
|
{{ order.renter_growth_level_name || '成长等级' }}优惠 ¥{{
|
||||||
|
money(amountYuan(order.rent_discount_amount_cent))
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isOwner && ownerIncomeDisplayAmount !== null" class="metric-card income">
|
<div v-if="isOwner && ownerIncomeDisplayAmount !== null" class="metric-card income">
|
||||||
<span class="metric-label">{{ ownerIncomeLabel }}</span>
|
<span class="metric-label">{{ ownerIncomeLabel }}</span>
|
||||||
|
|||||||
@@ -70,6 +70,19 @@ const realnameBadge = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const growthBadgeTone = computed(() => {
|
||||||
|
switch (session.renterGrowthLevel) {
|
||||||
|
case 'peak':
|
||||||
|
return 'peak'
|
||||||
|
case 'diamond':
|
||||||
|
return 'diamond'
|
||||||
|
case 'platinum':
|
||||||
|
return 'platinum'
|
||||||
|
default:
|
||||||
|
return 'normal'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [route.path, route.query.keyword],
|
() => [route.path, route.query.keyword],
|
||||||
() => {
|
() => {
|
||||||
@@ -198,6 +211,9 @@ async function handleSupportClick() {
|
|||||||
<span v-else class="pc-avatar-text">{{ session.avatarText }}</span>
|
<span v-else class="pc-avatar-text">{{ session.avatarText }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="pc-user-name">{{ session.displayName }}</span>
|
<span class="pc-user-name">{{ session.displayName }}</span>
|
||||||
|
<span class="pc-growth-badge" :class="`is-${growthBadgeTone}`">
|
||||||
|
{{ session.renterGrowthLevelName }}
|
||||||
|
</span>
|
||||||
<span class="pc-realname-badge" :class="`is-${realnameBadge.tone}`">
|
<span class="pc-realname-badge" :class="`is-${realnameBadge.tone}`">
|
||||||
{{ realnameBadge.text }}
|
{{ realnameBadge.text }}
|
||||||
</span>
|
</span>
|
||||||
@@ -209,6 +225,17 @@ async function handleSupportClick() {
|
|||||||
<Transition name="dropdown">
|
<Transition name="dropdown">
|
||||||
<div v-if="showUserDropdown" class="pc-user-dropdown-container">
|
<div v-if="showUserDropdown" class="pc-user-dropdown-container">
|
||||||
<div class="pc-user-dropdown">
|
<div class="pc-user-dropdown">
|
||||||
|
<div class="dropdown-growth-panel">
|
||||||
|
<div>
|
||||||
|
<span>租客成长等级</span>
|
||||||
|
<strong>{{ session.renterGrowthLevelName }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>当前积分</span>
|
||||||
|
<strong>{{ session.renterGrowthPoints }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="dropdown-basic-grid">
|
<div class="dropdown-basic-grid">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
class="dropdown-basic-item"
|
class="dropdown-basic-item"
|
||||||
@@ -664,6 +691,40 @@ async function handleSupportClick() {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pc-growth-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 42px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-growth-badge.is-normal {
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-growth-badge.is-platinum {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-growth-badge.is-diamond {
|
||||||
|
background: #ecfeff;
|
||||||
|
color: #0891b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pc-growth-badge.is-peak {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
.pc-realname-badge.is-verified,
|
.pc-realname-badge.is-verified,
|
||||||
.dropdown-status.is-verified {
|
.dropdown-status.is-verified {
|
||||||
background: #ecfdf3;
|
background: #ecfdf3;
|
||||||
@@ -733,6 +794,40 @@ async function handleSupportClick() {
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-growth-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-growth-panel > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 62px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-growth-panel span {
|
||||||
|
color: #8b9cb5;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-growth-panel strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #7c3aed;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-status {
|
.dropdown-status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -989,6 +1084,10 @@ async function handleSupportClick() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1280px) {
|
||||||
|
.pc-growth-badge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.pc-realname-badge {
|
.pc-realname-badge {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ import {
|
|||||||
} from '@/shared/utils/authStorage'
|
} from '@/shared/utils/authStorage'
|
||||||
import { loginWithPassword, registerWithPassword } from '@/features/auth/api/auth'
|
import { loginWithPassword, registerWithPassword } from '@/features/auth/api/auth'
|
||||||
|
|
||||||
|
function renterGrowthLevelName(level: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
normal: '普通',
|
||||||
|
platinum: '铂金',
|
||||||
|
diamond: '钻石',
|
||||||
|
peak: '巅峰',
|
||||||
|
}
|
||||||
|
return map[level] || '普通'
|
||||||
|
}
|
||||||
|
|
||||||
export const useSessionStore = defineStore('session', {
|
export const useSessionStore = defineStore('session', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
token: getAccessToken('user'),
|
token: getAccessToken('user'),
|
||||||
@@ -18,6 +28,8 @@ export const useSessionStore = defineStore('session', {
|
|||||||
nickname: localStorage.getItem('nickname') || '',
|
nickname: localStorage.getItem('nickname') || '',
|
||||||
avatarUrl: localStorage.getItem('avatar_url') || '',
|
avatarUrl: localStorage.getItem('avatar_url') || '',
|
||||||
realnameStatus: localStorage.getItem('realname_status') || 'unknown',
|
realnameStatus: localStorage.getItem('realname_status') || 'unknown',
|
||||||
|
renterGrowthPoints: Number(localStorage.getItem('renter_growth_points') || 0),
|
||||||
|
renterGrowthLevel: localStorage.getItem('renter_growth_level') || 'normal',
|
||||||
_loadingMe: false, // 防止重复调用
|
_loadingMe: false, // 防止重复调用
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
@@ -25,6 +37,7 @@ export const useSessionStore = defineStore('session', {
|
|||||||
displayName: state =>
|
displayName: state =>
|
||||||
state.nickname || (state.phone ? `用户${state.phone.slice(-6)}` : `用户${state.userId || 0}`),
|
state.nickname || (state.phone ? `用户${state.phone.slice(-6)}` : `用户${state.userId || 0}`),
|
||||||
avatarText: state => (state.nickname || state.phone || 'U').slice(0, 1),
|
avatarText: state => (state.nickname || state.phone || 'U').slice(0, 1),
|
||||||
|
renterGrowthLevelName: state => renterGrowthLevelName(state.renterGrowthLevel),
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
async login(phone: string, code: string) {
|
async login(phone: string, code: string) {
|
||||||
@@ -78,6 +91,8 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.nickname = ''
|
this.nickname = ''
|
||||||
this.avatarUrl = ''
|
this.avatarUrl = ''
|
||||||
this.realnameStatus = 'unknown'
|
this.realnameStatus = 'unknown'
|
||||||
|
this.renterGrowthPoints = 0
|
||||||
|
this.renterGrowthLevel = 'normal'
|
||||||
clearAuthStorage('user')
|
clearAuthStorage('user')
|
||||||
},
|
},
|
||||||
syncFromStorage() {
|
syncFromStorage() {
|
||||||
@@ -88,6 +103,8 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.nickname = localStorage.getItem('nickname') || ''
|
this.nickname = localStorage.getItem('nickname') || ''
|
||||||
this.avatarUrl = localStorage.getItem('avatar_url') || ''
|
this.avatarUrl = localStorage.getItem('avatar_url') || ''
|
||||||
this.realnameStatus = localStorage.getItem('realname_status') || 'unknown'
|
this.realnameStatus = localStorage.getItem('realname_status') || 'unknown'
|
||||||
|
this.renterGrowthPoints = Number(localStorage.getItem('renter_growth_points') || 0)
|
||||||
|
this.renterGrowthLevel = localStorage.getItem('renter_growth_level') || 'normal'
|
||||||
},
|
},
|
||||||
applySession(user: AuthUser, accessToken: string, refreshToken: string) {
|
applySession(user: AuthUser, accessToken: string, refreshToken: string) {
|
||||||
this.token = accessToken
|
this.token = accessToken
|
||||||
@@ -111,6 +128,10 @@ export const useSessionStore = defineStore('session', {
|
|||||||
localStorage.setItem('avatar_url', user.avatar_url)
|
localStorage.setItem('avatar_url', user.avatar_url)
|
||||||
this.realnameStatus = user.realname_status
|
this.realnameStatus = user.realname_status
|
||||||
localStorage.setItem('realname_status', user.realname_status)
|
localStorage.setItem('realname_status', user.realname_status)
|
||||||
|
this.renterGrowthPoints = Number(user.renter_growth_points || 0)
|
||||||
|
localStorage.setItem('renter_growth_points', String(this.renterGrowthPoints))
|
||||||
|
this.renterGrowthLevel = user.renter_growth_level || 'normal'
|
||||||
|
localStorage.setItem('renter_growth_level', this.renterGrowthLevel)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user