diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index 4b58ffb..ab3f7d3 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -8,47 +8,53 @@ import ( ) type RentalOrder struct { - ID uint64 `gorm:"primaryKey" json:"id"` - OrderNo string `gorm:"size:64;not null;uniqueIndex" json:"order_no"` - ListingID uint64 `gorm:"not null;index" json:"listing_id"` - AccountID uint64 `gorm:"not null;index" json:"account_id"` - OwnerID uint64 `gorm:"not null;index" json:"owner_id"` - RenterID uint64 `gorm:"not null;index" json:"renter_id"` - RentedAt *time.Time `json:"rented_at"` - HandoffStartedAt *time.Time `json:"handoff_started_at"` - EstimatedDurationHours int `gorm:"not null;default:24" json:"estimated_duration_hours"` - RentAmountCent int64 `gorm:"not null;default:0" json:"-"` - OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"` - DepositAmountCent int64 `gorm:"not null;default:0" json:"-"` - DepositOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` - DepositWaivedAmountCent int64 `gorm:"not null;default:0" json:"-"` - DepositFreeLevelQuotaCent int64 `gorm:"not null;default:0" json:"deposit_free_level_quota_cent"` - DepositFreeManualQuotaCent int64 `gorm:"not null;default:0" json:"deposit_free_manual_quota_cent"` - DepositFreeUsedBeforeCent int64 `gorm:"not null;default:0" json:"deposit_free_used_before_cent"` - PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"` - BasePriceCent int64 `gorm:"not null;default:0" json:"-"` - ShelfMarkupCent int64 `gorm:"not null;default:0" json:"-"` - MarkupSiteID uint64 `gorm:"not null;default:0;index" json:"markup_site_id,omitempty"` - ShelfRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"shelf_ratio,omitempty"` - ShelfRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"shelf_ratio_delta,omitempty"` - RentOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` - RentDiscountAmountCent int64 `gorm:"not null;default:0" json:"-"` - PureCoinOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` - ExtraItemOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` - ActualCoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"actual_coin_consumed_m"` - ActualPureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"` - ActualPureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"` - ActualExtraItemAmountCent 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"` - GrowthPointsBasisCent int64 `gorm:"not null;default:0" json:"growth_points_basis_cent"` - GrowthPointsPerYuan int64 `gorm:"not null;default:0" json:"growth_points_per_yuan"` - 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"` - AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"` - SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"` + ID uint64 `gorm:"primaryKey" json:"id"` + OrderNo string `gorm:"size:64;not null;uniqueIndex" json:"order_no"` + ListingID uint64 `gorm:"not null;index" json:"listing_id"` + AccountID uint64 `gorm:"not null;index" json:"account_id"` + OwnerID uint64 `gorm:"not null;index" json:"owner_id"` + RenterID uint64 `gorm:"not null;index" json:"renter_id"` + RentedAt *time.Time `json:"rented_at"` + HandoffStartedAt *time.Time `json:"handoff_started_at"` + EstimatedDurationHours int `gorm:"not null;default:24" json:"estimated_duration_hours"` + RentAmountCent int64 `gorm:"not null;default:0" json:"-"` + OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"` + DepositAmountCent int64 `gorm:"not null;default:0" json:"-"` + DepositOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` + DepositWaivedAmountCent int64 `gorm:"not null;default:0" json:"-"` + DepositFreeLevelQuotaCent int64 `gorm:"not null;default:0" json:"deposit_free_level_quota_cent"` + DepositFreeManualQuotaCent int64 `gorm:"not null;default:0" json:"deposit_free_manual_quota_cent"` + DepositFreeUsedBeforeCent int64 `gorm:"not null;default:0" json:"deposit_free_used_before_cent"` + PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"` + BasePriceCent int64 `gorm:"not null;default:0" json:"-"` + ShelfMarkupCent int64 `gorm:"not null;default:0" json:"-"` + MarkupSiteID uint64 `gorm:"not null;default:0;index" json:"markup_site_id,omitempty"` + ShelfRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"shelf_ratio,omitempty"` + ShelfRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"shelf_ratio_delta,omitempty"` + RentOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` + RentDiscountAmountCent int64 `gorm:"not null;default:0" json:"-"` + PureCoinOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` + ExtraItemOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` + ActualCoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"actual_coin_consumed_m"` + ActualPureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"` + ActualPureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"` + ActualExtraItemAmountCent 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"` + GrowthPointsBasisCent int64 `gorm:"not null;default:0" json:"growth_points_basis_cent"` + GrowthPointsPerYuan int64 `gorm:"not null;default:0" json:"growth_points_per_yuan"` + GrowthPointsMultiplierBps int `gorm:"not null;default:10000" json:"growth_points_multiplier_bps"` + GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"` + GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"` + OwnerGrowthPointsBasisCent int64 `gorm:"not null;default:0" json:"owner_growth_points_basis_cent"` + OwnerGrowthPointsPerYuan int64 `gorm:"not null;default:0" json:"owner_growth_points_per_yuan"` + OwnerGrowthPointsMultiplierBps int `gorm:"not null;default:10000" json:"owner_growth_points_multiplier_bps"` + OwnerGrowthPointsAwarded int64 `gorm:"not null;default:0" json:"owner_growth_points_awarded"` + OwnerGrowthPointsAwardedAt *time.Time `json:"owner_growth_points_awarded_at"` + AccountSnapshot datatypes.JSON `json:"account_snapshot"` + AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"` + SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"` // 分销正常订单字段:仅在卖家和租客都通过分销网站完成交易时填写。 DistributionSiteID *uint64 `gorm:"index" json:"distribution_site_id,omitempty"` DistributionMode string `gorm:"size:32;not null;default:'';index" json:"distribution_mode"` diff --git a/backend/internal/model/points_mall.go b/backend/internal/model/points_mall.go new file mode 100644 index 0000000..a422309 --- /dev/null +++ b/backend/internal/model/points_mall.go @@ -0,0 +1,47 @@ +package model + +import "time" + +type PointsMallProduct struct { + ID uint64 `gorm:"primaryKey" json:"id"` + Title string `gorm:"size:128;not null" json:"title"` + Subtitle string `gorm:"size:255;not null;default:''" json:"subtitle"` + Description string `gorm:"type:text;not null" json:"description"` + CoverURL string `gorm:"size:512;not null;default:''" json:"cover_url"` + RedemptionInstructions string `gorm:"type:text;not null" json:"redemption_instructions"` + PointsPrice int64 `gorm:"not null;default:0" json:"points_price"` + Stock int64 `gorm:"not null;default:0" json:"stock"` + RedeemedCount int64 `gorm:"not null;default:0" json:"redeemed_count"` + PerUserLimit int `gorm:"not null;default:1" json:"per_user_limit"` + Status string `gorm:"size:16;not null;default:'offline'" json:"status"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (PointsMallProduct) TableName() string { + return "points_mall_products" +} + +type PointsMallRedemption struct { + ID uint64 `gorm:"primaryKey" json:"id"` + RedemptionNo string `gorm:"size:32;not null;uniqueIndex" json:"redemption_no"` + UserID uint64 `gorm:"not null;index" json:"user_id"` + ProductID uint64 `gorm:"not null;index" json:"product_id"` + ProductTitle string `gorm:"size:128;not null" json:"product_title"` + ProductCoverURL string `gorm:"size:512;not null;default:''" json:"product_cover_url"` + RedemptionInstructions string `gorm:"type:text;not null" json:"redemption_instructions"` + PointsPrice int64 `gorm:"not null;default:0" json:"points_price"` + Quantity int `gorm:"not null;default:1" json:"quantity"` + TotalPoints int64 `gorm:"not null;default:0" json:"total_points"` + Status string `gorm:"size:16;not null;default:'pending'" json:"status"` + AdminRemark string `gorm:"size:255;not null;default:''" json:"admin_remark"` + CompletedBy *uint64 `json:"completed_by,omitempty"` + CompletedAt *time.Time `json:"completed_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (PointsMallRedemption) TableName() string { + return "points_mall_redemptions" +} diff --git a/backend/internal/model/renter_growth.go b/backend/internal/model/renter_growth.go index 252ff51..bb3feb0 100644 --- a/backend/internal/model/renter_growth.go +++ b/backend/internal/model/renter_growth.go @@ -4,20 +4,26 @@ import "time" // RenterGrowthLedger 记录租客成长积分变动流水,便于排查订单完成后的积分发放。 type RenterGrowthLedger struct { - ID uint64 `gorm:"primaryKey" json:"id"` - UserID uint64 `gorm:"not null;index" json:"user_id"` - OrderID *uint64 `gorm:"index;uniqueIndex:uk_renter_growth_order_source,priority:1" json:"order_id,omitempty"` - Points int64 `gorm:"not null;default:0" json:"points"` - BasisAmountCent int64 `gorm:"not null;default:0" json:"basis_amount_cent"` - PointsPerYuan int64 `gorm:"not null;default:0" json:"points_per_yuan"` - 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"` - OperatorAdminID *uint64 `gorm:"index" json:"operator_admin_id,omitempty"` - Remark string `gorm:"size:255;not null;default:''" json:"remark"` - CreatedAt time.Time `json:"created_at"` + ID uint64 `gorm:"primaryKey" json:"id"` + UserID uint64 `gorm:"not null;index" json:"user_id"` + Role string `gorm:"size:16;not null;default:'renter';uniqueIndex:uk_renter_growth_order_role_source,priority:3" json:"role"` + OrderID *uint64 `gorm:"index;uniqueIndex:uk_renter_growth_order_role_source,priority:1" json:"order_id,omitempty"` + RedemptionID uint64 `gorm:"not null;default:0" json:"redemption_id"` + Points int64 `gorm:"not null;default:0" json:"points"` + BasisAmountCent int64 `gorm:"not null;default:0" json:"basis_amount_cent"` + PointsPerYuan int64 `gorm:"not null;default:0" json:"points_per_yuan"` + PointsMultiplierBps int `gorm:"not null;default:10000" json:"points_multiplier_bps"` + BeforePoints int64 `gorm:"not null;default:0" json:"before_points"` + AfterPoints int64 `gorm:"not null;default:0" json:"after_points"` + AvailableBefore int64 `gorm:"not null;default:0" json:"available_before"` + AvailableAfter int64 `gorm:"not null;default:0" json:"available_after"` + 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_role_source,priority:2" json:"source"` + BizKey *string `gorm:"size:128;uniqueIndex:uk_renter_growth_biz_key" json:"biz_key,omitempty"` + OperatorAdminID *uint64 `gorm:"index" json:"operator_admin_id,omitempty"` + Remark string `gorm:"size:255;not null;default:''" json:"remark"` + CreatedAt time.Time `json:"created_at"` } func (RenterGrowthLedger) TableName() string { diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 143bcca..41351a3 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -14,6 +14,7 @@ type User struct { 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"` + PointsAvailable int64 `gorm:"not null;default:0" json:"points_available"` Status string `gorm:"size:32;not null;default:'active'" json:"status"` QualityOwnerStatus string `gorm:"size:16;not null;default:'none';index:idx_users_quality_owner_status" json:"quality_owner_status"` QualityOwnerDepositCent int64 `gorm:"not null;default:0" json:"quality_owner_deposit_cent"` diff --git a/backend/internal/modules/adminuser/dto.go b/backend/internal/modules/adminuser/dto.go index e591adb..7febd0f 100644 --- a/backend/internal/modules/adminuser/dto.go +++ b/backend/internal/modules/adminuser/dto.go @@ -16,6 +16,7 @@ type UserDTO struct { DepositFreeUsedCent int64 `json:"deposit_free_used_cent"` DepositFreeRemainingCent int64 `json:"deposit_free_remaining_cent"` RenterGrowthPoints int64 `json:"renter_growth_points"` + PointsAvailable int64 `json:"points_available"` RenterGrowthLevel string `json:"renter_growth_level"` RenterGrowthLevelName string `json:"renter_growth_level_name"` AvailableBalanceCent int64 `json:"available_balance_cent"` diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index ebf8cbf..f9ea1dc 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -192,6 +192,12 @@ func (r *Repository) AdjustGrowthPoints(ctx context.Context, adminID uint64, use return err } beforePoints := user.RenterGrowthPoints + beforeAvailable := user.PointsAvailable + pointsDelta := req.TargetPoints - beforePoints + afterAvailable := beforeAvailable + pointsDelta + if afterAvailable < 0 { + afterAvailable = 0 + } beforeLevel := rentergrowth.LevelForPoints(cfg, beforePoints) afterLevel := rentergrowth.LevelForPoints(cfg, req.TargetPoints) if !cfg.Enabled { @@ -200,20 +206,25 @@ func (r *Repository) AdjustGrowthPoints(ctx context.Context, adminID uint64, use } user.RenterGrowthPoints = req.TargetPoints user.RenterGrowthLevel = afterLevel.Code + user.PointsAvailable = afterAvailable if err := tx.Save(&user).Error; err != nil { return err } ledger := model.RenterGrowthLedger{ - UserID: user.ID, - Points: req.TargetPoints - beforePoints, - BeforePoints: beforePoints, - AfterPoints: req.TargetPoints, - BeforeLevel: beforeLevel.Code, - AfterLevel: afterLevel.Code, - Source: rentergrowth.SourceAdminAdjustment, - OperatorAdminID: &adminID, - Remark: "后台调整成长积分: " + req.Reason, - CreatedAt: time.Now(), + UserID: user.ID, + Role: "admin", + Points: pointsDelta, + PointsMultiplierBps: afterLevel.PointsMultiplierBps, + BeforePoints: beforePoints, + AfterPoints: req.TargetPoints, + AvailableBefore: beforeAvailable, + AvailableAfter: afterAvailable, + BeforeLevel: beforeLevel.Code, + AfterLevel: afterLevel.Code, + Source: rentergrowth.SourceAdminAdjustment, + OperatorAdminID: &adminID, + Remark: "后台调整成长积分: " + req.Reason, + CreatedAt: time.Now(), } if err := tx.Create(&ledger).Error; err != nil { return err @@ -222,7 +233,7 @@ func (r *Repository) AdjustGrowthPoints(ctx context.Context, adminID uint64, use "user_id": user.ID, "before_points": beforePoints, "after_points": req.TargetPoints, - "change_points": req.TargetPoints - beforePoints, + "change_points": pointsDelta, "before_level": beforeLevel.Code, "after_level": afterLevel.Code, "reason": req.Reason, @@ -562,6 +573,7 @@ func (row userRow) toDTO(growthConfig rentergrowth.Config) UserDTO { DepositFreeUsedCent: row.DepositFreeUsedCent, DepositFreeRemainingCent: remaining, RenterGrowthPoints: row.RenterGrowthPoints, + PointsAvailable: row.PointsAvailable, RenterGrowthLevel: level.Code, RenterGrowthLevelName: level.Name, AvailableBalanceCent: row.AvailableBalanceCent, diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index 35ae953..91d983b 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -127,7 +127,13 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r } } if order.DistributionSiteID == nil || *order.DistributionSiteID == 0 { - if err := rentergrowth.AwardOrderCompleted(tx, &order, actualSettlement.PureCoinAmountCent, rentergrowth.SourceOrderCompleted); err != nil { + if err := rentergrowth.AwardOrderBothCompleted( + tx, + &order, + actualSettlement.ActualRentAmountCent, + actualSettlement.OwnerRentIncomeCent, + rentergrowth.SourceOrderCompleted, + ); err != nil { return err } } diff --git a/backend/internal/modules/dispute/repository_test.go b/backend/internal/modules/dispute/repository_test.go index cd18487..a667c4e 100644 --- a/backend/internal/modules/dispute/repository_test.go +++ b/backend/internal/modules/dispute/repository_test.go @@ -430,8 +430,8 @@ func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) { if err := db.First(&saved, order.ID).Error; err != nil { t.Fatalf("读取订单失败: %v", err) } - if saved.ActualCoinConsumedM != 50 || saved.ActualPureCoinAmountCent != 13150 || saved.ActualExtraItemAmountCent != 12000 || saved.GrowthPointsAwarded != 131 { - t.Fatalf("最终 M/纯币/额外物品/积分 = %.2f/%d/%d/%d, want 50/13150/12000/131", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.ActualExtraItemAmountCent, saved.GrowthPointsAwarded) + if saved.ActualCoinConsumedM != 50 || saved.ActualPureCoinAmountCent != 13150 || saved.ActualExtraItemAmountCent != 12000 || saved.GrowthPointsAwarded != 250 { + t.Fatalf("最终 M/纯币/额外物品/积分 = %.2f/%d/%d/%d, want 50/13150/12000/250", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.ActualExtraItemAmountCent, saved.GrowthPointsAwarded) } } @@ -526,8 +526,8 @@ func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) { if err := db.First(&saved, order.ID).Error; err != nil { t.Fatalf("读取订单失败: %v", err) } - if saved.ActualCoinConsumedM != 25 || saved.ActualPureCoinAmountCent != 6575 || saved.ActualExtraItemAmountCent != 7000 || saved.GrowthPointsAwarded != 65 { - t.Fatalf("最终 M/纯币/额外物品/积分 = %.2f/%d/%d/%d, want 25/6575/7000/65", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.ActualExtraItemAmountCent, saved.GrowthPointsAwarded) + if saved.ActualCoinConsumedM != 25 || saved.ActualPureCoinAmountCent != 6575 || saved.ActualExtraItemAmountCent != 7000 || saved.GrowthPointsAwarded != 135 { + t.Fatalf("最终 M/纯币/额外物品/积分 = %.2f/%d/%d/%d, want 25/6575/7000/135", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.ActualExtraItemAmountCent, saved.GrowthPointsAwarded) } var savedCheckout model.OrderCheckout if err := db.First(&savedCheckout, checkout.ID).Error; err != nil { diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index 499be95..f99e11d 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -51,7 +51,13 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che return nil, err } if order.DistributionSiteID == nil || *order.DistributionSiteID == 0 { - if err := rentergrowth.AwardOrderCompleted(tx, order, settlement.PureCoinAmountCent, rentergrowth.SourceOrderCompleted); err != nil { + if err := rentergrowth.AwardOrderBothCompleted( + tx, + order, + settlement.ActualRentAmountCent, + settlement.OwnerRentIncomeCent, + rentergrowth.SourceOrderCompleted, + ); err != nil { return nil, err } } diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index a84c399..3a1bfe5 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -9,85 +9,91 @@ import ( ) type OrderDTO struct { - ID uint64 `json:"id"` - OrderNo string `json:"order_no"` - ListingID uint64 `json:"listing_id"` - ListingNo string `json:"listing_no"` - AccountID uint64 `json:"account_id"` - OwnerID uint64 `json:"owner_id"` - RenterID uint64 `json:"renter_id"` - OwnerPhone string `json:"owner_phone,omitempty"` - RenterPhone string `json:"renter_phone,omitempty"` - Title string `json:"title"` - ServerRegion string `json:"server_region"` - LoginPlatform string `json:"login_platform"` - RentedAt *time.Time `json:"rented_at"` - EstimatedDurationHours int `json:"estimated_duration_hours"` - EstimatedEndAt *time.Time `json:"estimated_end_at,omitempty"` - PriceRole string `json:"price_role,omitempty"` - DisplayAmountCent int64 `json:"display_amount_cent"` - RentAmountCent *int64 `json:"rent_amount_cent,omitempty"` - OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"` - DepositAmountCent int64 `json:"deposit_amount_cent"` - DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"` - DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"` - DepositFreeLevelQuotaCent int64 `json:"deposit_free_level_quota_cent,omitempty"` - DepositFreeManualQuotaCent int64 `json:"deposit_free_manual_quota_cent,omitempty"` - DepositFreeUsedBeforeCent int64 `json:"deposit_free_used_before_cent,omitempty"` - PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"` - BasePriceCent int64 `json:"base_price_cent,omitempty"` - ShelfMarkupCent int64 `json:"shelf_markup_cent,omitempty"` - MarkupSiteID uint64 `json:"markup_site_id,omitempty"` - ShelfRatio float64 `json:"shelf_ratio,omitempty"` - ShelfRatioDelta float64 `json:"shelf_ratio_delta,omitempty"` - RentOriginalAmountCent int64 `json:"rent_original_amount_cent,omitempty"` - RentDiscountAmountCent int64 `json:"rent_discount_amount_cent,omitempty"` - PureCoinOriginalAmountCent int64 `json:"pure_coin_original_amount_cent,omitempty"` - ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"` - ActualCoinConsumedM float64 `json:"actual_coin_consumed_m,omitempty"` - ActualPureCoinAmountCent int64 `json:"actual_pure_coin_amount_cent,omitempty"` - ActualPureCoinDiscountCent int64 `json:"actual_pure_coin_discount_cent,omitempty"` - ActualExtraItemAmountCent int64 `json:"actual_extra_item_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"` - GrowthPointsBasisCent int64 `json:"growth_points_basis_cent,omitempty"` - GrowthPointsPerYuan int64 `json:"growth_points_per_yuan,omitempty"` - GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"` - GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"` - AccountSnapshot datatypes.JSON `json:"account_snapshot"` - AccountSource string `json:"account_source"` - SourceChannel string `json:"source_channel,omitempty"` - IsPremium bool `json:"is_premium"` - CompensationPlan string `json:"compensation_plan"` - InsuranceFeeBps int `json:"insurance_fee_bps"` - InsuranceFeeCent int64 `json:"insurance_fee_cent"` - Status string `json:"status"` - HandoffStatus string `json:"handoff_status"` - HandoffMode string `json:"handoff_mode"` - SettlementMode string `json:"settlement_mode"` - ManagedAdminID *uint64 `json:"managed_admin_id,omitempty"` - SettlementStatus string `json:"settlement_status"` - OfflineSettlementStatus string `json:"offline_settlement_status,omitempty"` - OfflineSettlementAmountCent int64 `json:"offline_settlement_amount_cent"` - OfflineSettlementRemark string `json:"offline_settlement_remark,omitempty"` - OfflineSettledBy *uint64 `json:"offline_settled_by,omitempty"` - OfflineSettledAt *time.Time `json:"offline_settled_at,omitempty"` - RefundStatus string `json:"refund_status,omitempty"` - RefundAmountCent int64 `json:"refund_amount_cent"` - DepositHoldStatus string `json:"deposit_hold_status,omitempty"` - DepositHoldAmountCent int64 `json:"deposit_hold_amount_cent"` - DepositHoldReason string `json:"deposit_hold_reason,omitempty"` - DepositHeldAt *time.Time `json:"deposit_held_at,omitempty"` - DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at,omitempty"` - ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"` - Checkout *CheckoutDTO `json:"checkout,omitempty"` - AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"` - PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"` - DistributionSiteID *uint64 `json:"distribution_site_id,omitempty"` - SourceTrackCode string `json:"source_track_code,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + OrderNo string `json:"order_no"` + ListingID uint64 `json:"listing_id"` + ListingNo string `json:"listing_no"` + AccountID uint64 `json:"account_id"` + OwnerID uint64 `json:"owner_id"` + RenterID uint64 `json:"renter_id"` + OwnerPhone string `json:"owner_phone,omitempty"` + RenterPhone string `json:"renter_phone,omitempty"` + Title string `json:"title"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + RentedAt *time.Time `json:"rented_at"` + EstimatedDurationHours int `json:"estimated_duration_hours"` + EstimatedEndAt *time.Time `json:"estimated_end_at,omitempty"` + PriceRole string `json:"price_role,omitempty"` + DisplayAmountCent int64 `json:"display_amount_cent"` + RentAmountCent *int64 `json:"rent_amount_cent,omitempty"` + OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"` + DepositAmountCent int64 `json:"deposit_amount_cent"` + DepositOriginalAmountCent int64 `json:"deposit_original_amount_cent"` + DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"` + DepositFreeLevelQuotaCent int64 `json:"deposit_free_level_quota_cent,omitempty"` + DepositFreeManualQuotaCent int64 `json:"deposit_free_manual_quota_cent,omitempty"` + DepositFreeUsedBeforeCent int64 `json:"deposit_free_used_before_cent,omitempty"` + PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"` + BasePriceCent int64 `json:"base_price_cent,omitempty"` + ShelfMarkupCent int64 `json:"shelf_markup_cent,omitempty"` + MarkupSiteID uint64 `json:"markup_site_id,omitempty"` + ShelfRatio float64 `json:"shelf_ratio,omitempty"` + ShelfRatioDelta float64 `json:"shelf_ratio_delta,omitempty"` + RentOriginalAmountCent int64 `json:"rent_original_amount_cent,omitempty"` + RentDiscountAmountCent int64 `json:"rent_discount_amount_cent,omitempty"` + PureCoinOriginalAmountCent int64 `json:"pure_coin_original_amount_cent,omitempty"` + ExtraItemOriginalAmountCent int64 `json:"extra_item_original_amount_cent,omitempty"` + ActualCoinConsumedM float64 `json:"actual_coin_consumed_m,omitempty"` + ActualPureCoinAmountCent int64 `json:"actual_pure_coin_amount_cent,omitempty"` + ActualPureCoinDiscountCent int64 `json:"actual_pure_coin_discount_cent,omitempty"` + ActualExtraItemAmountCent int64 `json:"actual_extra_item_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"` + GrowthPointsBasisCent int64 `json:"growth_points_basis_cent,omitempty"` + GrowthPointsPerYuan int64 `json:"growth_points_per_yuan,omitempty"` + GrowthPointsMultiplierBps int `json:"growth_points_multiplier_bps,omitempty"` + GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"` + GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"` + OwnerGrowthPointsBasisCent int64 `json:"owner_growth_points_basis_cent,omitempty"` + OwnerGrowthPointsPerYuan int64 `json:"owner_growth_points_per_yuan,omitempty"` + OwnerGrowthPointsMultiplierBps int `json:"owner_growth_points_multiplier_bps,omitempty"` + OwnerGrowthPointsAwarded int64 `json:"owner_growth_points_awarded,omitempty"` + OwnerGrowthPointsAwardedAt *time.Time `json:"owner_growth_points_awarded_at,omitempty"` + AccountSnapshot datatypes.JSON `json:"account_snapshot"` + AccountSource string `json:"account_source"` + SourceChannel string `json:"source_channel,omitempty"` + IsPremium bool `json:"is_premium"` + CompensationPlan string `json:"compensation_plan"` + InsuranceFeeBps int `json:"insurance_fee_bps"` + InsuranceFeeCent int64 `json:"insurance_fee_cent"` + Status string `json:"status"` + HandoffStatus string `json:"handoff_status"` + HandoffMode string `json:"handoff_mode"` + SettlementMode string `json:"settlement_mode"` + ManagedAdminID *uint64 `json:"managed_admin_id,omitempty"` + SettlementStatus string `json:"settlement_status"` + OfflineSettlementStatus string `json:"offline_settlement_status,omitempty"` + OfflineSettlementAmountCent int64 `json:"offline_settlement_amount_cent"` + OfflineSettlementRemark string `json:"offline_settlement_remark,omitempty"` + OfflineSettledBy *uint64 `json:"offline_settled_by,omitempty"` + OfflineSettledAt *time.Time `json:"offline_settled_at,omitempty"` + RefundStatus string `json:"refund_status,omitempty"` + RefundAmountCent int64 `json:"refund_amount_cent"` + DepositHoldStatus string `json:"deposit_hold_status,omitempty"` + DepositHoldAmountCent int64 `json:"deposit_hold_amount_cent"` + DepositHoldReason string `json:"deposit_hold_reason,omitempty"` + DepositHeldAt *time.Time `json:"deposit_held_at,omitempty"` + DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at,omitempty"` + ActiveDispute *ActiveDisputeDTO `json:"active_dispute,omitempty"` + Checkout *CheckoutDTO `json:"checkout,omitempty"` + AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"` + PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"` + DistributionSiteID *uint64 `json:"distribution_site_id,omitempty"` + SourceTrackCode string `json:"source_track_code,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // UserOrderListItemDTO 是“我的订单”列表的最小展示数据,详情数据仅由单订单接口返回。 diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index 2c9f703..6c372ad 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -66,82 +66,88 @@ func (row orderRow) toAdminDTO() OrderDTO { ownerRentAmountCent := row.OwnerRentAmountCent platformFeeCent := row.PlatformFeeCent return OrderDTO{ - ID: row.ID, - OrderNo: row.OrderNo, - ListingID: row.ListingID, - ListingNo: row.ListingNo, - AccountID: row.AccountID, - OwnerID: row.OwnerID, - RenterID: row.RenterID, - OwnerPhone: row.OwnerPhone, - RenterPhone: row.RenterPhone, - Title: row.Title, - ServerRegion: row.ServerRegion, - LoginPlatform: row.LoginPlatform, - RentedAt: rentedAt, - EstimatedDurationHours: durationHours, - EstimatedEndAt: estimatedEndAt, - PriceRole: "admin", - DisplayAmountCent: row.RentAmountCent, - RentAmountCent: &rentAmountCent, - OwnerRentAmountCent: &ownerRentAmountCent, - DepositAmountCent: row.DepositAmountCent, - DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder), - DepositWaivedAmountCent: row.DepositWaivedAmountCent, - DepositFreeLevelQuotaCent: row.DepositFreeLevelQuotaCent, - DepositFreeManualQuotaCent: row.DepositFreeManualQuotaCent, - DepositFreeUsedBeforeCent: row.DepositFreeUsedBeforeCent, - PlatformFeeCent: &platformFeeCent, - BasePriceCent: row.BasePriceCent, - ShelfMarkupCent: row.ShelfMarkupCent, - MarkupSiteID: row.MarkupSiteID, - ShelfRatio: row.ShelfRatio, - ShelfRatioDelta: row.ShelfRatioDelta, - RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder), - RentDiscountAmountCent: row.RentDiscountAmountCent, - PureCoinOriginalAmountCent: orderPureCoinOriginalAmountCent(row.RentalOrder), - ExtraItemOriginalAmountCent: orderExtraItemOriginalAmountCent(row.RentalOrder), - ActualCoinConsumedM: row.ActualCoinConsumedM, - ActualPureCoinAmountCent: row.ActualPureCoinAmountCent, - ActualPureCoinDiscountCent: row.ActualPureCoinDiscountCent, - ActualExtraItemAmountCent: row.ActualExtraItemAmountCent, - RenterGrowthLevel: effectiveRenterGrowthLevel(row.RentalOrder), - RenterGrowthLevelName: effectiveRenterGrowthLevelName(row.RentalOrder), - RenterDiscountBps: effectiveRenterDiscountBps(row.RentalOrder), - GrowthPointsBasisCent: row.GrowthPointsBasisCent, - GrowthPointsPerYuan: row.GrowthPointsPerYuan, - GrowthPointsAwarded: row.GrowthPointsAwarded, - GrowthPointsAwardedAt: row.GrowthPointsAwardedAt, - AccountSnapshot: row.AccountSnapshot, - AccountSource: effectiveAccountSource(row.RentalOrder), - SourceChannel: row.SourceChannel, - IsPremium: row.IsPremium, - CompensationPlan: normalizedCompensationPlan(row.CompensationPlan), - InsuranceFeeBps: row.InsuranceFeeBps, - InsuranceFeeCent: row.InsuranceFeeCent, - Status: row.Status, - HandoffStatus: row.HandoffStatus, - HandoffMode: effectiveHandoffMode(row.RentalOrder), - SettlementMode: effectiveSettlementMode(row.RentalOrder), - ManagedAdminID: row.ManagedAdminID, - SettlementStatus: row.SettlementStatus, - OfflineSettlementStatus: effectiveOfflineSettlementStatus(row.RentalOrder), - OfflineSettlementAmountCent: row.OfflineSettlementAmountCent, - OfflineSettlementRemark: row.OfflineSettlementRemark, - OfflineSettledBy: row.OfflineSettledBy, - OfflineSettledAt: row.OfflineSettledAt, - RefundStatus: row.RefundStatus, - RefundAmountCent: row.RefundAmountCent, - DepositHoldStatus: row.DepositHoldStatus, - DepositHoldAmountCent: row.DepositHoldAmountCent, - DepositHoldReason: row.DepositHoldReason, - DepositHeldAt: row.DepositHeldAt, - DepositHoldReleasedAt: row.DepositHoldReleasedAt, - AdminActions: adminActionsForOrder(row.RentalOrder), - DistributionSiteID: row.DistributionSiteID, - SourceTrackCode: row.SourceTrackCode, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + ID: row.ID, + OrderNo: row.OrderNo, + ListingID: row.ListingID, + ListingNo: row.ListingNo, + AccountID: row.AccountID, + OwnerID: row.OwnerID, + RenterID: row.RenterID, + OwnerPhone: row.OwnerPhone, + RenterPhone: row.RenterPhone, + Title: row.Title, + ServerRegion: row.ServerRegion, + LoginPlatform: row.LoginPlatform, + RentedAt: rentedAt, + EstimatedDurationHours: durationHours, + EstimatedEndAt: estimatedEndAt, + PriceRole: "admin", + DisplayAmountCent: row.RentAmountCent, + RentAmountCent: &rentAmountCent, + OwnerRentAmountCent: &ownerRentAmountCent, + DepositAmountCent: row.DepositAmountCent, + DepositOriginalAmountCent: effectiveDepositOriginalAmountCent(row.RentalOrder), + DepositWaivedAmountCent: row.DepositWaivedAmountCent, + DepositFreeLevelQuotaCent: row.DepositFreeLevelQuotaCent, + DepositFreeManualQuotaCent: row.DepositFreeManualQuotaCent, + DepositFreeUsedBeforeCent: row.DepositFreeUsedBeforeCent, + PlatformFeeCent: &platformFeeCent, + BasePriceCent: row.BasePriceCent, + ShelfMarkupCent: row.ShelfMarkupCent, + MarkupSiteID: row.MarkupSiteID, + ShelfRatio: row.ShelfRatio, + ShelfRatioDelta: row.ShelfRatioDelta, + RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder), + RentDiscountAmountCent: row.RentDiscountAmountCent, + PureCoinOriginalAmountCent: orderPureCoinOriginalAmountCent(row.RentalOrder), + ExtraItemOriginalAmountCent: orderExtraItemOriginalAmountCent(row.RentalOrder), + ActualCoinConsumedM: row.ActualCoinConsumedM, + ActualPureCoinAmountCent: row.ActualPureCoinAmountCent, + ActualPureCoinDiscountCent: row.ActualPureCoinDiscountCent, + ActualExtraItemAmountCent: row.ActualExtraItemAmountCent, + RenterGrowthLevel: effectiveRenterGrowthLevel(row.RentalOrder), + RenterGrowthLevelName: effectiveRenterGrowthLevelName(row.RentalOrder), + RenterDiscountBps: effectiveRenterDiscountBps(row.RentalOrder), + GrowthPointsBasisCent: row.GrowthPointsBasisCent, + GrowthPointsPerYuan: row.GrowthPointsPerYuan, + GrowthPointsMultiplierBps: row.GrowthPointsMultiplierBps, + GrowthPointsAwarded: row.GrowthPointsAwarded, + GrowthPointsAwardedAt: row.GrowthPointsAwardedAt, + OwnerGrowthPointsBasisCent: row.OwnerGrowthPointsBasisCent, + OwnerGrowthPointsPerYuan: row.OwnerGrowthPointsPerYuan, + OwnerGrowthPointsMultiplierBps: row.OwnerGrowthPointsMultiplierBps, + OwnerGrowthPointsAwarded: row.OwnerGrowthPointsAwarded, + OwnerGrowthPointsAwardedAt: row.OwnerGrowthPointsAwardedAt, + AccountSnapshot: row.AccountSnapshot, + AccountSource: effectiveAccountSource(row.RentalOrder), + SourceChannel: row.SourceChannel, + IsPremium: row.IsPremium, + CompensationPlan: normalizedCompensationPlan(row.CompensationPlan), + InsuranceFeeBps: row.InsuranceFeeBps, + InsuranceFeeCent: row.InsuranceFeeCent, + Status: row.Status, + HandoffStatus: row.HandoffStatus, + HandoffMode: effectiveHandoffMode(row.RentalOrder), + SettlementMode: effectiveSettlementMode(row.RentalOrder), + ManagedAdminID: row.ManagedAdminID, + SettlementStatus: row.SettlementStatus, + OfflineSettlementStatus: effectiveOfflineSettlementStatus(row.RentalOrder), + OfflineSettlementAmountCent: row.OfflineSettlementAmountCent, + OfflineSettlementRemark: row.OfflineSettlementRemark, + OfflineSettledBy: row.OfflineSettledBy, + OfflineSettledAt: row.OfflineSettledAt, + RefundStatus: row.RefundStatus, + RefundAmountCent: row.RefundAmountCent, + DepositHoldStatus: row.DepositHoldStatus, + DepositHoldAmountCent: row.DepositHoldAmountCent, + DepositHoldReason: row.DepositHoldReason, + DepositHeldAt: row.DepositHeldAt, + DepositHoldReleasedAt: row.DepositHoldReleasedAt, + AdminActions: adminActionsForOrder(row.RentalOrder), + DistributionSiteID: row.DistributionSiteID, + SourceTrackCode: row.SourceTrackCode, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, } } @@ -431,6 +437,7 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64) dto.GrowthPointsAwardedAt = nil dto.GrowthPointsBasisCent = 0 dto.GrowthPointsPerYuan = 0 + dto.GrowthPointsMultiplierBps = 0 sanitizeOrderSnapshot(&dto.AccountSnapshot, "owner") case userID == order.RenterID: rentAmountCent := order.RentAmountCent @@ -443,6 +450,11 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64) dto.RenterGrowthLevel = effectiveRenterGrowthLevel(order) dto.RenterGrowthLevelName = effectiveRenterGrowthLevelName(order) dto.RenterDiscountBps = effectiveRenterDiscountBps(order) + dto.OwnerGrowthPointsBasisCent = 0 + dto.OwnerGrowthPointsPerYuan = 0 + dto.OwnerGrowthPointsMultiplierBps = 0 + dto.OwnerGrowthPointsAwarded = 0 + dto.OwnerGrowthPointsAwardedAt = nil sanitizeOrderSnapshot(&dto.AccountSnapshot, "renter") default: dto.PriceRole = "" @@ -464,6 +476,12 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64) dto.GrowthPointsAwardedAt = nil dto.GrowthPointsBasisCent = 0 dto.GrowthPointsPerYuan = 0 + dto.GrowthPointsMultiplierBps = 0 + dto.OwnerGrowthPointsBasisCent = 0 + dto.OwnerGrowthPointsPerYuan = 0 + dto.OwnerGrowthPointsMultiplierBps = 0 + dto.OwnerGrowthPointsAwarded = 0 + dto.OwnerGrowthPointsAwardedAt = nil sanitizeOrderSnapshot(&dto.AccountSnapshot, "") } } diff --git a/backend/internal/modules/pointsmall/dto.go b/backend/internal/modules/pointsmall/dto.go new file mode 100644 index 0000000..260e477 --- /dev/null +++ b/backend/internal/modules/pointsmall/dto.go @@ -0,0 +1,123 @@ +package pointsmall + +import ( + "errors" + "time" + + "hfb_sys/backend/internal/model" +) + +var ( + ErrDependencyUnavailable = errors.New("points mall dependency unavailable") + ErrInvalidInput = errors.New("invalid points mall input") + ErrProductUnavailable = errors.New("points mall product unavailable") + ErrInsufficientPoints = errors.New("insufficient points") + ErrRedeemLimitExceeded = errors.New("redeem limit exceeded") + ErrRedemptionNotPending = errors.New("redemption not pending") +) + +type SummaryDTO struct { + AvailablePoints int64 `json:"available_points"` + LifetimePoints int64 `json:"lifetime_points"` + LevelCode string `json:"level_code"` + LevelName string `json:"level_name"` + PointsPerYuan int64 `json:"points_per_yuan"` + PointsMultiplierBps int `json:"points_multiplier_bps"` +} + +type ProductDTO struct { + ID uint64 `json:"id"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Description string `json:"description"` + CoverURL string `json:"cover_url"` + RedemptionInstructions string `json:"redemption_instructions"` + PointsPrice int64 `json:"points_price"` + Stock int64 `json:"stock"` + RedeemedCount int64 `json:"redeemed_count"` + PerUserLimit int `json:"per_user_limit"` + Status string `json:"status"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ProductRequest struct { + Title string `json:"title" binding:"required,max=128"` + Subtitle string `json:"subtitle" binding:"max=255"` + Description string `json:"description" binding:"required"` + CoverURL string `json:"cover_url"` + RedemptionInstructions string `json:"redemption_instructions" binding:"required"` + PointsPrice int64 `json:"points_price" binding:"required,min=1"` + Stock int64 `json:"stock" binding:"min=0"` + PerUserLimit int `json:"per_user_limit" binding:"required,min=1,max=100"` + Status string `json:"status" binding:"required,oneof=online offline"` + SortOrder int `json:"sort_order"` +} + +type RedeemRequest struct { + Quantity int `json:"quantity" binding:"required,min=1,max=10"` +} + +type CompleteRedemptionRequest struct { + Remark string `json:"remark" binding:"max=255"` +} + +type RedemptionDTO struct { + ID uint64 `json:"id"` + RedemptionNo string `json:"redemption_no"` + UserID uint64 `json:"user_id"` + UserPhone string `json:"user_phone,omitempty"` + UserNickname string `json:"user_nickname,omitempty"` + ProductID uint64 `json:"product_id"` + ProductTitle string `json:"product_title"` + ProductCoverURL string `json:"product_cover_url"` + RedemptionInstructions string `json:"redemption_instructions"` + PointsPrice int64 `json:"points_price"` + Quantity int `json:"quantity"` + TotalPoints int64 `json:"total_points"` + Status string `json:"status"` + AdminRemark string `json:"admin_remark"` + CompletedBy *uint64 `json:"completed_by,omitempty"` + CompletedAt *time.Time `json:"completed_at"` + CreatedAt time.Time `json:"created_at"` +} + +func toProductDTO(item model.PointsMallProduct) ProductDTO { + return ProductDTO{ + ID: item.ID, + Title: item.Title, + Subtitle: item.Subtitle, + Description: item.Description, + CoverURL: item.CoverURL, + RedemptionInstructions: item.RedemptionInstructions, + PointsPrice: item.PointsPrice, + Stock: item.Stock, + RedeemedCount: item.RedeemedCount, + PerUserLimit: item.PerUserLimit, + Status: item.Status, + SortOrder: item.SortOrder, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +func toRedemptionDTO(item model.PointsMallRedemption) RedemptionDTO { + return RedemptionDTO{ + ID: item.ID, + RedemptionNo: item.RedemptionNo, + UserID: item.UserID, + ProductID: item.ProductID, + ProductTitle: item.ProductTitle, + ProductCoverURL: item.ProductCoverURL, + RedemptionInstructions: item.RedemptionInstructions, + PointsPrice: item.PointsPrice, + Quantity: item.Quantity, + TotalPoints: item.TotalPoints, + Status: item.Status, + AdminRemark: item.AdminRemark, + CompletedBy: item.CompletedBy, + CompletedAt: item.CompletedAt, + CreatedAt: item.CreatedAt, + } +} diff --git a/backend/internal/modules/pointsmall/handler.go b/backend/internal/modules/pointsmall/handler.go new file mode 100644 index 0000000..0b50d21 --- /dev/null +++ b/backend/internal/modules/pointsmall/handler.go @@ -0,0 +1,208 @@ +package pointsmall + +import ( + "errors" + "net/http" + "strconv" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) Summary(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + item, err := h.service.Summary(c.Request.Context(), userID) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Ledger(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + items, err := h.service.Ledger(c.Request.Context(), userID, parseLimit(c.Query("limit"))) + if err != nil { + writeError(c, err) + return + } + response.OK(c, items) +} + +func (h *Handler) Products(c *gin.Context) { + items, err := h.service.ListProducts(c.Request.Context(), false) + if err != nil { + writeError(c, err) + return + } + response.OK(c, items) +} + +func (h *Handler) AdminProducts(c *gin.Context) { + items, err := h.service.ListProducts(c.Request.Context(), true) + if err != nil { + writeError(c, err) + return + } + response.OK(c, items) +} + +func (h *Handler) CreateProduct(c *gin.Context) { + var req ProductRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "积分商品参数不正确") + return + } + item, err := h.service.CreateProduct(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) UpdateProduct(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if id == 0 { + response.BadRequest(c, "商品 ID 不正确") + return + } + var req ProductRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "积分商品参数不正确") + return + } + item, err := h.service.UpdateProduct(c.Request.Context(), id, req) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Redeem(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + productID, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var req RedeemRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "兑换数量不正确") + return + } + item, err := h.service.Redeem(c.Request.Context(), userID, productID, req.Quantity) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) MyRedemptions(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + items, err := h.service.ListRedemptions(c.Request.Context(), userID, false) + if err != nil { + writeError(c, err) + return + } + response.OK(c, items) +} + +func (h *Handler) AdminRedemptions(c *gin.Context) { + items, err := h.service.ListRedemptions(c.Request.Context(), 0, true) + if err != nil { + writeError(c, err) + return + } + response.OK(c, items) +} + +func (h *Handler) CompleteRedemption(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var req CompleteRedemptionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "处理备注不正确") + return + } + item, err := h.service.CompleteRedemption(c.Request.Context(), adminID, id, req.Remark) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func currentUserID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextUserID) + if !ok { + return 0, false + } + id, ok := value.(uint64) + return id, ok +} + +func currentAdminID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextAdminID) + if !ok { + return 0, false + } + id, ok := value.(uint64) + return id, ok +} + +func parseLimit(value string) int { + limit, err := strconv.Atoi(value) + if err != nil { + return 100 + } + return limit +} + +func writeError(c *gin.Context, err error) { + response.RecordError(c, err) + switch { + case errors.Is(err, ErrInvalidInput): + response.BadRequest(c, "积分商城参数不正确") + case errors.Is(err, ErrProductUnavailable): + response.Error(c, http.StatusConflict, "points_product_unavailable", "商品已下架或库存不足") + case errors.Is(err, ErrInsufficientPoints): + response.Error(c, http.StatusConflict, "insufficient_points", "可用积分不足") + case errors.Is(err, ErrRedeemLimitExceeded): + response.Error(c, http.StatusConflict, "points_redeem_limit", "已超过该商品限兑数量") + case errors.Is(err, ErrRedemptionNotPending): + response.Error(c, http.StatusConflict, "points_redemption_processed", "该兑换记录已处理") + case IsNotFound(err): + response.Error(c, http.StatusNotFound, "not_found", "积分商城数据不存在") + default: + response.Error(c, http.StatusInternalServerError, "internal_error", "积分商城服务暂时不可用") + } +} diff --git a/backend/internal/modules/pointsmall/repository.go b/backend/internal/modules/pointsmall/repository.go new file mode 100644 index 0000000..4e08e1d --- /dev/null +++ b/backend/internal/modules/pointsmall/repository.go @@ -0,0 +1,271 @@ +package pointsmall + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/rentergrowth" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) Summary(ctx context.Context, userID uint64) (*SummaryDTO, error) { + var user model.User + if err := r.db.WithContext(ctx).First(&user, userID).Error; err != nil { + return nil, err + } + cfg, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx)) + if err != nil { + return nil, err + } + level := rentergrowth.LevelForPoints(cfg, user.RenterGrowthPoints) + return &SummaryDTO{ + AvailablePoints: user.PointsAvailable, + LifetimePoints: user.RenterGrowthPoints, + LevelCode: level.Code, + LevelName: level.Name, + PointsPerYuan: cfg.PointsPerYuan, + PointsMultiplierBps: level.PointsMultiplierBps, + }, nil +} + +func (r *Repository) Ledger(ctx context.Context, userID uint64, limit int) ([]model.RenterGrowthLedger, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + var rows []model.RenterGrowthLedger + err := r.db.WithContext(ctx). + Where("user_id = ?", userID). + Order("created_at DESC, id DESC"). + Limit(limit). + Find(&rows).Error + return rows, err +} + +func (r *Repository) ListProducts(ctx context.Context, includeOffline bool) ([]ProductDTO, error) { + var rows []model.PointsMallProduct + tx := r.db.WithContext(ctx).Order("sort_order DESC, id DESC") + if !includeOffline { + tx = tx.Where("status = ?", "online") + } + if err := tx.Find(&rows).Error; err != nil { + return nil, err + } + items := make([]ProductDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, toProductDTO(row)) + } + return items, nil +} + +func (r *Repository) CreateProduct(ctx context.Context, req ProductRequest) (*ProductDTO, error) { + item := productFromRequest(req) + if err := r.db.WithContext(ctx).Create(&item).Error; err != nil { + return nil, err + } + dto := toProductDTO(item) + return &dto, nil +} + +func (r *Repository) UpdateProduct(ctx context.Context, id uint64, req ProductRequest) (*ProductDTO, error) { + var item model.PointsMallProduct + if err := r.db.WithContext(ctx).First(&item, id).Error; err != nil { + return nil, err + } + next := productFromRequest(req) + next.ID = item.ID + next.RedeemedCount = item.RedeemedCount + next.CreatedAt = item.CreatedAt + if err := r.db.WithContext(ctx).Model(&item).Updates(map[string]any{ + "title": next.Title, + "subtitle": next.Subtitle, + "description": next.Description, + "cover_url": next.CoverURL, + "redemption_instructions": next.RedemptionInstructions, + "points_price": next.PointsPrice, + "stock": next.Stock, + "per_user_limit": next.PerUserLimit, + "status": next.Status, + "sort_order": next.SortOrder, + }).Error; err != nil { + return nil, err + } + return r.findProduct(ctx, id) +} + +func (r *Repository) findProduct(ctx context.Context, id uint64) (*ProductDTO, error) { + var item model.PointsMallProduct + if err := r.db.WithContext(ctx).First(&item, id).Error; err != nil { + return nil, err + } + dto := toProductDTO(item) + return &dto, nil +} + +func (r *Repository) Redeem(ctx context.Context, userID, productID uint64, quantity int) (*RedemptionDTO, error) { + if userID == 0 || productID == 0 || quantity <= 0 || quantity > 10 { + return nil, ErrInvalidInput + } + var result model.PointsMallRedemption + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var product model.PointsMallProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, productID).Error; err != nil { + return err + } + if product.Status != "online" || product.Stock < int64(quantity) { + return ErrProductUnavailable + } + var redeemed int64 + if err := tx.Model(&model.PointsMallRedemption{}). + Where("product_id = ? AND user_id = ? AND status <> ?", productID, userID, "cancelled"). + Select("COALESCE(SUM(quantity), 0)"). + Scan(&redeemed).Error; err != nil { + return err + } + if redeemed+int64(quantity) > int64(product.PerUserLimit) { + return ErrRedeemLimitExceeded + } + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { + return err + } + totalPoints := product.PointsPrice * int64(quantity) + if totalPoints <= 0 || user.PointsAvailable < totalPoints { + return ErrInsufficientPoints + } + now := time.Now() + result = model.PointsMallRedemption{ + RedemptionNo: fmt.Sprintf("PM%d%d", userID, now.UnixNano()), + UserID: userID, + ProductID: product.ID, + ProductTitle: product.Title, + ProductCoverURL: product.CoverURL, + RedemptionInstructions: product.RedemptionInstructions, + PointsPrice: product.PointsPrice, + Quantity: quantity, + TotalPoints: totalPoints, + Status: "pending", + CreatedAt: now, + UpdatedAt: now, + } + if err := tx.Create(&result).Error; err != nil { + return err + } + beforePoints := user.PointsAvailable + user.PointsAvailable -= totalPoints + if err := tx.Save(&user).Error; err != nil { + return err + } + if err := tx.Model(&product).Updates(map[string]any{ + "stock": gorm.Expr("stock - ?", quantity), + "redeemed_count": gorm.Expr("redeemed_count + ?", quantity), + }).Error; err != nil { + return err + } + bizKey := fmt.Sprintf("mall:%d:redeem", result.ID) + ledger := model.RenterGrowthLedger{ + UserID: userID, + Role: "mall", + RedemptionID: result.ID, + Points: -totalPoints, + BeforePoints: user.RenterGrowthPoints, + AfterPoints: user.RenterGrowthPoints, + AvailableBefore: beforePoints, + AvailableAfter: user.PointsAvailable, + BeforeLevel: user.RenterGrowthLevel, + AfterLevel: user.RenterGrowthLevel, + Source: "mall_redeem", + BizKey: &bizKey, + Remark: "积分商城兑换: " + product.Title, + CreatedAt: now, + } + return tx.Create(&ledger).Error + }) + if err != nil { + return nil, err + } + dto := toRedemptionDTO(result) + return &dto, nil +} + +func (r *Repository) ListRedemptions(ctx context.Context, userID uint64, admin bool) ([]RedemptionDTO, error) { + type row struct { + model.PointsMallRedemption + UserPhone string + UserNickname string + } + var rows []row + tx := r.db.WithContext(ctx).Table("points_mall_redemptions AS r"). + Select("r.*, u.phone AS user_phone, u.nickname AS user_nickname"). + Joins("LEFT JOIN users AS u ON u.id = r.user_id"). + Order("r.created_at DESC, r.id DESC") + if !admin { + tx = tx.Where("r.user_id = ?", userID) + } + if err := tx.Scan(&rows).Error; err != nil { + return nil, err + } + items := make([]RedemptionDTO, 0, len(rows)) + for _, item := range rows { + dto := toRedemptionDTO(item.PointsMallRedemption) + dto.UserPhone = item.UserPhone + dto.UserNickname = item.UserNickname + items = append(items, dto) + } + return items, nil +} + +func (r *Repository) CompleteRedemption(ctx context.Context, adminID, id uint64, remark string) (*RedemptionDTO, error) { + var result model.PointsMallRedemption + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&result, id).Error; err != nil { + return err + } + if result.Status != "pending" { + return ErrRedemptionNotPending + } + now := time.Now() + result.Status = "completed" + result.AdminRemark = strings.TrimSpace(remark) + result.CompletedBy = &adminID + result.CompletedAt = &now + return tx.Save(&result).Error + }) + if err != nil { + return nil, err + } + dto := toRedemptionDTO(result) + return &dto, nil +} + +func productFromRequest(req ProductRequest) model.PointsMallProduct { + return model.PointsMallProduct{ + Title: strings.TrimSpace(req.Title), + Subtitle: strings.TrimSpace(req.Subtitle), + Description: strings.TrimSpace(req.Description), + CoverURL: strings.TrimSpace(req.CoverURL), + RedemptionInstructions: strings.TrimSpace(req.RedemptionInstructions), + PointsPrice: req.PointsPrice, + Stock: req.Stock, + PerUserLimit: req.PerUserLimit, + Status: req.Status, + SortOrder: req.SortOrder, + } +} + +func IsNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} diff --git a/backend/internal/modules/pointsmall/repository_test.go b/backend/internal/modules/pointsmall/repository_test.go new file mode 100644 index 0000000..efbda97 --- /dev/null +++ b/backend/internal/modules/pointsmall/repository_test.go @@ -0,0 +1,61 @@ +package pointsmall + +import ( + "errors" + "testing" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" +) + +func TestRedeemDeductsAvailablePointsAndStock(t *testing.T) { + db := database.NewTestDB() + user := model.User{Phone: "13900009601", PointsAvailable: 500} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user failed: %v", err) + } + product := model.PointsMallProduct{ + Title: "测试兑换码", + Description: "测试商品", + RedemptionInstructions: "联系客服领取", + PointsPrice: 100, + Stock: 2, + PerUserLimit: 2, + Status: "online", + } + if err := db.Create(&product).Error; err != nil { + t.Fatalf("create product failed: %v", err) + } + repo := NewRepository(db) + redemption, err := repo.Redeem(t.Context(), user.ID, product.ID, 2) + if err != nil { + t.Fatalf("redeem failed: %v", err) + } + if redemption.TotalPoints != 200 { + t.Fatalf("total points = %d, want 200", redemption.TotalPoints) + } + var savedUser model.User + if err := db.First(&savedUser, user.ID).Error; err != nil { + t.Fatalf("find user failed: %v", err) + } + if savedUser.PointsAvailable != 300 || savedUser.RenterGrowthPoints != 0 { + t.Fatalf("points after redeem = available %d lifetime %d", savedUser.PointsAvailable, savedUser.RenterGrowthPoints) + } + var savedProduct model.PointsMallProduct + if err := db.First(&savedProduct, product.ID).Error; err != nil { + t.Fatalf("find product failed: %v", err) + } + if savedProduct.Stock != 0 || savedProduct.RedeemedCount != 2 { + t.Fatalf("product stock/redeemed = %d/%d", savedProduct.Stock, savedProduct.RedeemedCount) + } + if _, err := repo.Redeem(t.Context(), user.ID, product.ID, 1); !errors.Is(err, ErrRedeemLimitExceeded) && !errors.Is(err, ErrProductUnavailable) { + t.Fatalf("repeat redeem error = %v, want limit or unavailable", err) + } + completed, err := repo.CompleteRedemption(t.Context(), 99, redemption.ID, "已发放") + if err != nil { + t.Fatalf("complete redemption failed: %v", err) + } + if completed.Status != "completed" { + t.Fatalf("redemption status = %s, want completed", completed.Status) + } +} diff --git a/backend/internal/modules/pointsmall/service.go b/backend/internal/modules/pointsmall/service.go new file mode 100644 index 0000000..0c30215 --- /dev/null +++ b/backend/internal/modules/pointsmall/service.go @@ -0,0 +1,47 @@ +package pointsmall + +import ( + "context" + + "hfb_sys/backend/internal/model" +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Summary(ctx context.Context, userID uint64) (*SummaryDTO, error) { + return s.repo.Summary(ctx, userID) +} + +func (s *Service) Ledger(ctx context.Context, userID uint64, limit int) ([]model.RenterGrowthLedger, error) { + return s.repo.Ledger(ctx, userID, limit) +} + +func (s *Service) ListProducts(ctx context.Context, includeOffline bool) ([]ProductDTO, error) { + return s.repo.ListProducts(ctx, includeOffline) +} + +func (s *Service) CreateProduct(ctx context.Context, req ProductRequest) (*ProductDTO, error) { + return s.repo.CreateProduct(ctx, req) +} + +func (s *Service) UpdateProduct(ctx context.Context, id uint64, req ProductRequest) (*ProductDTO, error) { + return s.repo.UpdateProduct(ctx, id, req) +} + +func (s *Service) Redeem(ctx context.Context, userID, productID uint64, quantity int) (*RedemptionDTO, error) { + return s.repo.Redeem(ctx, userID, productID, quantity) +} + +func (s *Service) ListRedemptions(ctx context.Context, userID uint64, admin bool) ([]RedemptionDTO, error) { + return s.repo.ListRedemptions(ctx, userID, admin) +} + +func (s *Service) CompleteRedemption(ctx context.Context, adminID, id uint64, remark string) (*RedemptionDTO, error) { + return s.repo.CompleteRedemption(ctx, adminID, id, remark) +} diff --git a/backend/internal/modules/rentergrowth/growth.go b/backend/internal/modules/rentergrowth/growth.go index 98e254b..b502192 100644 --- a/backend/internal/modules/rentergrowth/growth.go +++ b/backend/internal/modules/rentergrowth/growth.go @@ -3,6 +3,7 @@ package rentergrowth import ( "encoding/json" "errors" + "fmt" "sort" "strings" "time" @@ -19,6 +20,8 @@ const ( DefaultLevelName = "普通" DefaultDiscountBps = 10000 DefaultPointsPerYuan = 1 + DefaultMultiplierBps = 10_000 + MaxPointsMultiplierBps = 100_000 DefaultPlatinumQuotaCent = 50_000 DefaultDiamondQuotaCent = 100_000 DefaultPeakQuotaCent = 150_000 @@ -50,6 +53,7 @@ type LevelRule struct { Code string `json:"code"` Name string `json:"name"` MinPoints int64 `json:"min_points"` + PointsMultiplierBps int `json:"points_multiplier_bps"` DiscountBps int `json:"discount_bps"` DepositFreeQuotaCent int64 `json:"deposit_free_quota_cent"` } @@ -69,10 +73,10 @@ func DefaultConfig() Config { Enabled: true, PointsPerYuan: DefaultPointsPerYuan, Levels: []LevelRule{ - {Code: DefaultLevelCode, Name: DefaultLevelName, MinPoints: 0, DiscountBps: DefaultDiscountBps, DepositFreeQuotaCent: 0}, - {Code: "platinum", Name: "铂金", MinPoints: 300, DiscountBps: 9900, DepositFreeQuotaCent: DefaultPlatinumQuotaCent}, - {Code: "diamond", Name: "钻石", MinPoints: 1000, DiscountBps: 9800, DepositFreeQuotaCent: DefaultDiamondQuotaCent}, - {Code: "peak", Name: "巅峰", MinPoints: 5000, DiscountBps: 9500, DepositFreeQuotaCent: DefaultPeakQuotaCent}, + {Code: DefaultLevelCode, Name: DefaultLevelName, MinPoints: 0, PointsMultiplierBps: 10_000, DiscountBps: DefaultDiscountBps, DepositFreeQuotaCent: 0}, + {Code: "platinum", Name: "铂金", MinPoints: 300, PointsMultiplierBps: 12_000, DiscountBps: 9900, DepositFreeQuotaCent: DefaultPlatinumQuotaCent}, + {Code: "diamond", Name: "钻石", MinPoints: 1000, PointsMultiplierBps: 15_000, DiscountBps: 9800, DepositFreeQuotaCent: DefaultDiamondQuotaCent}, + {Code: "peak", Name: "巅峰", MinPoints: 5000, PointsMultiplierBps: 20_000, DiscountBps: 9500, DepositFreeQuotaCent: DefaultPeakQuotaCent}, }, } } @@ -83,6 +87,7 @@ func (level *LevelRule) UnmarshalJSON(data []byte) error { Code string `json:"code"` Name string `json:"name"` MinPoints int64 `json:"min_points"` + PointsMultiplierBps *int `json:"points_multiplier_bps"` DiscountBps int `json:"discount_bps"` DepositFreeQuotaCent *int64 `json:"deposit_free_quota_cent"` } @@ -94,10 +99,15 @@ func (level *LevelRule) UnmarshalJSON(data []byte) error { if raw.DepositFreeQuotaCent != nil { quotaCent = *raw.DepositFreeQuotaCent } + multiplierBps := defaultPointsMultiplierBps(raw.Code) + if raw.PointsMultiplierBps != nil { + multiplierBps = *raw.PointsMultiplierBps + } *level = LevelRule{ Code: raw.Code, Name: raw.Name, MinPoints: raw.MinPoints, + PointsMultiplierBps: multiplierBps, DiscountBps: raw.DiscountBps, DepositFreeQuotaCent: quotaCent, } @@ -151,7 +161,7 @@ func ValidateConfig(cfg Config) error { if !ok || strings.TrimSpace(level.Name) != fixed.Name { return errors.New("missing renter growth level") } - if level.MinPoints < 0 || level.MinPoints > MaxLevelPoints || level.DiscountBps <= 0 || level.DiscountBps > DefaultDiscountBps || level.DepositFreeQuotaCent < 0 || level.DepositFreeQuotaCent > MaxLevelQuotaCent { + if level.MinPoints < 0 || level.MinPoints > MaxLevelPoints || level.PointsMultiplierBps <= 0 || level.PointsMultiplierBps > MaxPointsMultiplierBps || level.DiscountBps <= 0 || level.DiscountBps > DefaultDiscountBps || level.DepositFreeQuotaCent < 0 || level.DepositFreeQuotaCent > MaxLevelQuotaCent { return errors.New("invalid renter growth level value") } if index == 0 { @@ -161,7 +171,7 @@ func ValidateConfig(cfg Config) error { previous = level continue } - if level.MinPoints <= previous.MinPoints || level.DiscountBps > previous.DiscountBps || level.DepositFreeQuotaCent < previous.DepositFreeQuotaCent { + if level.MinPoints <= previous.MinPoints || level.PointsMultiplierBps < previous.PointsMultiplierBps || level.DiscountBps > previous.DiscountBps || level.DepositFreeQuotaCent < previous.DepositFreeQuotaCent { return errors.New("renter growth levels must increase") } previous = level @@ -245,120 +255,197 @@ func CalculateDiscountCent(originalRentCent int64, platformFeeCent int64, discou } func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualPureCoinCent int64, source string) error { + return AwardOrderBothCompleted(tx, order, actualPureCoinCent, 0, source) +} + +// AwardOrderBothCompleted 按租客实际支出和卖家实际收入分别发放积分。 +func AwardOrderBothCompleted(tx *gorm.DB, order *model.RentalOrder, renterBasisCent int64, ownerBasisCent int64, source string) error { if tx == nil || order == nil || order.ID == 0 || order.RenterID == 0 { return nil } - if order.GrowthPointsAwarded > 0 || order.GrowthPointsAwardedAt != nil { - return nil - } if strings.TrimSpace(source) == "" { source = SourceOrderCompleted } + cfg, err := ConfigForTx(tx) + if err != nil { + return err + } + if err := awardOrderSide(tx, order, cfg, "renter", order.RenterID, renterBasisCent, source); err != nil { + return err + } + if order.OwnerID > 0 && order.OwnerID != order.RenterID { + return awardOrderSide(tx, order, cfg, "owner", order.OwnerID, ownerBasisCent, source) + } + return nil +} + +func awardOrderSide(tx *gorm.DB, order *model.RentalOrder, cfg Config, role string, userID uint64, basisCent int64, source string) error { + basisCent = maxInt64(basisCent, 0) + bizKey := fmt.Sprintf("order:%d:%s:%s", order.ID, role, source) var existing model.RenterGrowthLedger - err := tx.Where("order_id = ? AND source = ?", order.ID, source).First(&existing).Error + err := tx.Where("biz_key = ?", bizKey).First(&existing).Error if err == nil { - order.GrowthPointsAwarded = existing.Points - if !existing.CreatedAt.IsZero() { - awardedAt := existing.CreatedAt - order.GrowthPointsAwardedAt = &awardedAt - } + setOrderPointsSnapshot(order, role, existing.BasisAmountCent, existing.PointsPerYuan, existing.PointsMultiplierBps, existing.Points, existing.CreatedAt) return nil } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } - - cfg, err := ConfigForTx(tx) - if err != nil { - return err + if role == "renter" { + order.GrowthPointsBasisCent = basisCent + order.GrowthPointsPerYuan = cfg.PointsPerYuan + } else { + order.OwnerGrowthPointsBasisCent = basisCent + order.OwnerGrowthPointsPerYuan = cfg.PointsPerYuan } - order.GrowthPointsBasisCent = maxInt64(actualPureCoinCent, 0) - order.GrowthPointsPerYuan = cfg.PointsPerYuan - if !cfg.Enabled { - return nil - } - points := PointsForPureCoin(actualPureCoinCent, cfg.PointsPerYuan) - if points <= 0 { + if !cfg.Enabled || basisCent <= 0 { return nil } var user model.User - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, order.RenterID).Error; err != nil { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { return err } beforePoints := user.RenterGrowthPoints + beforeAvailable := user.PointsAvailable beforeLevel := LevelForPoints(cfg, beforePoints) + multiplierBps := beforeLevel.PointsMultiplierBps + points := PointsForAmount(basisCent, cfg.PointsPerYuan, multiplierBps) + if points <= 0 { + if role == "renter" { + order.GrowthPointsMultiplierBps = multiplierBps + } else { + order.OwnerGrowthPointsMultiplierBps = multiplierBps + } + return nil + } afterPoints := beforePoints + points afterLevel := LevelForPoints(cfg, afterPoints) user.RenterGrowthPoints = afterPoints user.RenterGrowthLevel = afterLevel.Code + user.PointsAvailable = beforeAvailable + points if err := tx.Save(&user).Error; err != nil { return err } now := time.Now() ledger := model.RenterGrowthLedger{ - UserID: order.RenterID, - OrderID: &order.ID, - Points: points, - BasisAmountCent: actualPureCoinCent, - PointsPerYuan: cfg.PointsPerYuan, - BeforePoints: beforePoints, - AfterPoints: afterPoints, - BeforeLevel: beforeLevel.Code, - AfterLevel: afterLevel.Code, - Source: source, - Remark: "租号订单完成发放成长积分", - CreatedAt: now, + UserID: userID, + Role: role, + OrderID: &order.ID, + Points: points, + BasisAmountCent: basisCent, + PointsPerYuan: cfg.PointsPerYuan, + PointsMultiplierBps: multiplierBps, + BeforePoints: beforePoints, + AfterPoints: afterPoints, + AvailableBefore: beforeAvailable, + AvailableAfter: user.PointsAvailable, + BeforeLevel: beforeLevel.Code, + AfterLevel: afterLevel.Code, + Source: source, + BizKey: &bizKey, + Remark: "订单完成发放积分", + CreatedAt: now, } if err := tx.Create(&ledger).Error; err != nil { return err } - order.GrowthPointsAwarded = points - order.GrowthPointsAwardedAt = &now + setOrderPointsSnapshot(order, role, basisCent, cfg.PointsPerYuan, multiplierBps, points, now) return nil } +func setOrderPointsSnapshot(order *model.RentalOrder, role string, basisCent, perYuan int64, multiplierBps int, points int64, awardedAt time.Time) { + if role == "owner" { + order.OwnerGrowthPointsBasisCent = basisCent + order.OwnerGrowthPointsPerYuan = perYuan + order.OwnerGrowthPointsMultiplierBps = multiplierBps + order.OwnerGrowthPointsAwarded = points + if !awardedAt.IsZero() { + order.OwnerGrowthPointsAwardedAt = &awardedAt + } + return + } + order.GrowthPointsBasisCent = basisCent + order.GrowthPointsPerYuan = perYuan + order.GrowthPointsMultiplierBps = multiplierBps + order.GrowthPointsAwarded = points + if !awardedAt.IsZero() { + order.GrowthPointsAwardedAt = &awardedAt + } +} + // RevokeOrderCompleted 在已完成订单被全额人工退款后扣回该单已发放的成长积分。 func RevokeOrderCompleted(tx *gorm.DB, order *model.RentalOrder) error { if tx == nil || order == nil || order.ID == 0 || order.RenterID == 0 { return nil } + cfg, err := ConfigForTx(tx) + if err != nil { + return err + } + for _, side := range []struct { + role string + userID uint64 + }{ + {role: "renter", userID: order.RenterID}, + {role: "owner", userID: order.OwnerID}, + } { + if side.userID == 0 { + continue + } + if err := revokeOrderSide(tx, order, cfg, side.role, side.userID); err != nil { + return err + } + } + return nil +} + +func revokeOrderSide(tx *gorm.DB, order *model.RentalOrder, cfg Config, role string, userID uint64) error { + bizKey := fmt.Sprintf("order:%d:%s:%s", order.ID, role, SourceOrderRefunded) var existing model.RenterGrowthLedger - err := tx.Where("order_id = ? AND source = ?", order.ID, SourceOrderRefunded).First(&existing).Error + err := tx.Where("biz_key = ?", bizKey).First(&existing).Error if err == nil { return nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return err } - - points := order.GrowthPointsAwarded - if points <= 0 { - var awarded model.RenterGrowthLedger - if err := tx.Where("order_id = ? AND source = ?", order.ID, SourceOrderCompleted).First(&awarded).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { + var awarded model.RenterGrowthLedger + if err := tx.Where( + "order_id = ? AND role = ? AND source = ?", + order.ID, + role, + SourceOrderCompleted, + ).First(&awarded).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + if role != "renter" || order.GrowthPointsAwarded <= 0 { return nil } + awarded = model.RenterGrowthLedger{ + UserID: userID, + Role: role, + OrderID: &order.ID, + Points: order.GrowthPointsAwarded, + BasisAmountCent: order.GrowthPointsBasisCent, + PointsPerYuan: order.GrowthPointsPerYuan, + PointsMultiplierBps: order.GrowthPointsMultiplierBps, + Source: SourceOrderCompleted, + } + } else { return err } - points = awarded.Points } + points := awarded.Points if points <= 0 { return nil } - - cfg, err := ConfigForTx(tx) - if err != nil { - return err - } var user model.User - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, order.RenterID).Error; err != nil { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { return err } beforePoints := maxInt64(user.RenterGrowthPoints, 0) - revokedPoints := points - if revokedPoints > beforePoints { - revokedPoints = beforePoints - } + beforeAvailable := maxInt64(user.PointsAvailable, 0) + revokedPoints := minInt64(points, beforePoints) + revokedAvailable := minInt64(points, beforeAvailable) beforeLevel := LevelForPoints(cfg, beforePoints) afterPoints := beforePoints - revokedPoints afterLevel := LevelForPoints(cfg, afterPoints) @@ -368,34 +455,46 @@ func RevokeOrderCompleted(tx *gorm.DB, order *model.RentalOrder) error { } user.RenterGrowthPoints = afterPoints user.RenterGrowthLevel = afterLevel.Code + user.PointsAvailable = beforeAvailable - revokedAvailable if err := tx.Save(&user).Error; err != nil { return err } - orderID := order.ID return tx.Create(&model.RenterGrowthLedger{ - UserID: order.RenterID, - OrderID: &orderID, - Points: -revokedPoints, - BasisAmountCent: order.GrowthPointsBasisCent, - PointsPerYuan: order.GrowthPointsPerYuan, - BeforePoints: beforePoints, - AfterPoints: afterPoints, - BeforeLevel: beforeLevel.Code, - AfterLevel: afterLevel.Code, - Source: SourceOrderRefunded, - Remark: "已完成订单全额退款扣回成长积分", - CreatedAt: time.Now(), + UserID: userID, + Role: role, + OrderID: &order.ID, + Points: -revokedPoints, + BasisAmountCent: awarded.BasisAmountCent, + PointsPerYuan: awarded.PointsPerYuan, + PointsMultiplierBps: awarded.PointsMultiplierBps, + BeforePoints: beforePoints, + AfterPoints: afterPoints, + AvailableBefore: beforeAvailable, + AvailableAfter: user.PointsAvailable, + BeforeLevel: beforeLevel.Code, + AfterLevel: afterLevel.Code, + Source: SourceOrderRefunded, + BizKey: &bizKey, + Remark: "已完成订单全额退款扣回积分", + CreatedAt: time.Now(), }).Error } func PointsForPureCoin(actualPureCoinCent int64, pointsPerYuan int64) int64 { - if actualPureCoinCent <= 0 { + return PointsForAmount(actualPureCoinCent, pointsPerYuan, DefaultMultiplierBps) +} + +func PointsForAmount(basisCent int64, pointsPerYuan int64, multiplierBps int) int64 { + if basisCent <= 0 { return 0 } if pointsPerYuan <= 0 { pointsPerYuan = DefaultPointsPerYuan } - return (actualPureCoinCent / 100) * pointsPerYuan + if multiplierBps <= 0 { + multiplierBps = DefaultMultiplierBps + } + return basisCent * pointsPerYuan * int64(multiplierBps) / (100 * DefaultMultiplierBps) } // PointsForRent 保留旧调用兼容,积分基数现统一解释为实际纯币金额。 @@ -463,6 +562,9 @@ func normalizeConfig(cfg Config) Config { if level.MinPoints < 0 { level.MinPoints = 0 } + if level.PointsMultiplierBps <= 0 || level.PointsMultiplierBps > MaxPointsMultiplierBps { + level.PointsMultiplierBps = defaultPointsMultiplierBps(level.Code) + } if level.DiscountBps <= 0 || level.DiscountBps > DefaultDiscountBps { level.DiscountBps = DefaultDiscountBps } @@ -481,6 +583,7 @@ func normalizeConfig(cfg Config) Config { Code: DefaultLevelCode, Name: DefaultLevelName, MinPoints: 0, + PointsMultiplierBps: DefaultMultiplierBps, DiscountBps: DefaultDiscountBps, DepositFreeQuotaCent: 0, }) @@ -505,6 +608,19 @@ func defaultDepositFreeQuotaCent(code string) int64 { } } +func defaultPointsMultiplierBps(code string) int { + switch strings.TrimSpace(code) { + case "platinum": + return 12_000 + case "diamond": + return 15_000 + case "peak": + return 20_000 + default: + return DefaultMultiplierBps + } +} + func addQuotaCent(left int64, right int64) int64 { left = maxInt64(left, 0) right = maxInt64(right, 0) @@ -522,6 +638,13 @@ func maxInt64(left int64, right int64) int64 { return right } +func minInt64(left int64, right int64) int64 { + if left < right { + return left + } + return right +} + func isMissingConfigTableError(err error) bool { if err == nil { return false diff --git a/backend/internal/modules/rentergrowth/growth_test.go b/backend/internal/modules/rentergrowth/growth_test.go index 70d8e14..ec5062a 100644 --- a/backend/internal/modules/rentergrowth/growth_test.go +++ b/backend/internal/modules/rentergrowth/growth_test.go @@ -21,6 +21,17 @@ func TestDefaultConfigIncludesDepositFreeQuota(t *testing.T) { t.Fatalf("等级 %s 免押额度 = %d, want %d", level.Code, level.DepositFreeQuotaCent, wants[level.Code]) } } + multipliers := map[string]int{ + DefaultLevelCode: 10_000, + "platinum": 12_000, + "diamond": 15_000, + "peak": 20_000, + } + for _, level := range cfg.Levels { + if level.PointsMultiplierBps != multipliers[level.Code] { + t.Fatalf("等级 %s 积分倍率 = %d, want %d", level.Code, level.PointsMultiplierBps, multipliers[level.Code]) + } + } } func TestLegacyConfigBackfillsQuotaButKeepsExplicitZero(t *testing.T) { @@ -32,6 +43,9 @@ func TestLegacyConfigBackfillsQuotaButKeepsExplicitZero(t *testing.T) { if LevelForPoints(cfg, 300).DepositFreeQuotaCent != DefaultPlatinumQuotaCent { t.Fatalf("旧配置未补齐铂金免押额度") } + if LevelForPoints(cfg, 300).PointsMultiplierBps != 12_000 { + t.Fatalf("旧配置未补齐铂金积分倍率") + } var raw map[string]any if err := json.Unmarshal([]byte(legacy), &raw); err != nil { @@ -210,6 +224,52 @@ func TestAwardOrderCompletedUpdatesUserAndIsIdempotent(t *testing.T) { } } +func TestAwardOrderBothSidesUsesActualBasesAndMultipliers(t *testing.T) { + db := database.NewTestDB() + renter := model.User{Phone: "13800007771"} + owner := model.User{ + Phone: "13800007772", + RenterGrowthPoints: 1000, + RenterGrowthLevel: "diamond", + } + if err := db.Create(&renter).Error; err != nil { + t.Fatalf("create renter failed: %v", err) + } + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("create owner failed: %v", err) + } + order := model.RentalOrder{ + OrderNo: "RG202611040001", + RenterID: renter.ID, + OwnerID: owner.ID, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create order failed: %v", err) + } + if err := AwardOrderBothCompleted(db, &order, 10_000, 5_000, SourceOrderCompleted); err != nil { + t.Fatalf("award both sides failed: %v", err) + } + if order.GrowthPointsAwarded != 100 || order.GrowthPointsMultiplierBps != 10_000 { + t.Fatalf("renter points snapshot = %d/%d, want 100/10000", order.GrowthPointsAwarded, order.GrowthPointsMultiplierBps) + } + if order.OwnerGrowthPointsAwarded != 75 || order.OwnerGrowthPointsMultiplierBps != 15_000 { + t.Fatalf("owner points snapshot = %d/%d, want 75/15000", order.OwnerGrowthPointsAwarded, order.OwnerGrowthPointsMultiplierBps) + } + var savedRenter, savedOwner model.User + if err := db.First(&savedRenter, renter.ID).Error; err != nil { + t.Fatalf("find renter failed: %v", err) + } + if err := db.First(&savedOwner, owner.ID).Error; err != nil { + t.Fatalf("find owner failed: %v", err) + } + if savedRenter.PointsAvailable != 100 || savedRenter.RenterGrowthPoints != 100 { + t.Fatalf("renter points = available %d lifetime %d, want 100/100", savedRenter.PointsAvailable, savedRenter.RenterGrowthPoints) + } + if savedOwner.PointsAvailable != 75 || savedOwner.RenterGrowthPoints != 1075 { + t.Fatalf("owner points = available %d lifetime %d, want 75/1075", savedOwner.PointsAvailable, savedOwner.RenterGrowthPoints) + } +} + func TestRevokeOrderCompletedRecordsIdempotencyWhenUserHasNoPoints(t *testing.T) { db := database.NewTestDB() if err := database.AutoMigrateIfMissing(db, diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index f4702b4..0288b86 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -35,6 +35,7 @@ import ( "hfb_sys/backend/internal/modules/paymentaccount" "hfb_sys/backend/internal/modules/paymentconfig" "hfb_sys/backend/internal/modules/pickup" + "hfb_sys/backend/internal/modules/pointsmall" "hfb_sys/backend/internal/modules/qualityowner" "hfb_sys/backend/internal/modules/realname" "hfb_sys/backend/internal/modules/supportgroup" @@ -319,6 +320,11 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { systemConfigHandler := systemconfig.NewHandler(systemConfigService) qualityOwnerService := qualityowner.NewService(qualityOwnerRepo) qualityOwnerHandler := qualityowner.NewHandler(qualityOwnerService) + var pointsMallRepo *pointsmall.Repository + if deps.DB != nil { + pointsMallRepo = pointsmall.NewRepository(deps.DB) + } + pointsMallHandler := pointsmall.NewHandler(pointsmall.NewService(pointsMallRepo)) var distributionRepo *distribution.Repository if deps.DB != nil { distributionRepo = distribution.NewRepository(deps.DB, deps.Redis) @@ -495,6 +501,17 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { qualityOwnerRoutes.POST("/payment", requireRealname, paymentHandler.StartQualityOwner) qualityOwnerRoutes.GET("/payment", paymentHandler.QueryQualityOwner) } + pointsRoutes := api.Group("/points", requireAuth) + { + pointsRoutes.GET("/summary", pointsMallHandler.Summary) + pointsRoutes.GET("/ledger", pointsMallHandler.Ledger) + } + pointsMallRoutes := api.Group("/points-mall", requireAuth) + { + pointsMallRoutes.GET("/products", pointsMallHandler.Products) + pointsMallRoutes.POST("/products/:id/redeem", pointsMallHandler.Redeem) + pointsMallRoutes.GET("/redemptions", pointsMallHandler.MyRedemptions) + } if invitationHandler != nil { api.GET("/invitations/code-info", invitationHandler.GetInviteInfo) @@ -788,6 +805,11 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname) adminRoutes.POST("/users/:id/wallet/adjust", requirePerm("user:wallet_adjust"), adminUserHandler.AdjustWallet) adminRoutes.POST("/users/:id/quality-owner", requirePerm("user:quality_owner"), qualityOwnerHandler.AdminSet) + adminRoutes.GET("/points-mall/products", requirePerm("points_mall:view"), pointsMallHandler.AdminProducts) + adminRoutes.POST("/points-mall/products", requirePerm("points_mall:manage"), pointsMallHandler.CreateProduct) + adminRoutes.PUT("/points-mall/products/:id", requirePerm("points_mall:manage"), pointsMallHandler.UpdateProduct) + adminRoutes.GET("/points-mall/redemptions", requirePerm("points_mall:view"), pointsMallHandler.AdminRedemptions) + adminRoutes.POST("/points-mall/redemptions/:id/complete", requirePerm("points_mall:manage"), pointsMallHandler.CompleteRedemption) adminRoutes.GET("/orders", requirePerm("order:list"), orderHandler.AdminList) adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing) adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail) diff --git a/backend/migrations/000095_points_mall.sql b/backend/migrations/000095_points_mall.sql new file mode 100644 index 0000000..d948aff --- /dev/null +++ b/backend/migrations/000095_points_mall.sql @@ -0,0 +1,114 @@ +-- +goose Up +ALTER TABLE users + ADD COLUMN points_available BIGINT NOT NULL DEFAULT 0 COMMENT '积分商城可用积分' AFTER renter_growth_level; + +UPDATE users +SET points_available = renter_growth_points +WHERE points_available = 0 AND renter_growth_points > 0; + +ALTER TABLE renter_growth_ledger + DROP KEY uk_renter_growth_order_source, + ADD COLUMN role VARCHAR(16) NOT NULL DEFAULT 'renter' COMMENT '积分角色: renter/owner/admin' AFTER user_id, + ADD COLUMN points_multiplier_bps INT NOT NULL DEFAULT 10000 COMMENT '积分倍率基点' AFTER points_per_yuan, + ADD COLUMN available_before BIGINT NOT NULL DEFAULT 0 COMMENT '变动前可用积分' AFTER after_points, + ADD COLUMN available_after BIGINT NOT NULL DEFAULT 0 COMMENT '变动后可用积分' AFTER available_before, + ADD COLUMN redemption_id BIGINT NOT NULL DEFAULT 0 COMMENT '积分商城兑换单 ID' AFTER order_id, + ADD COLUMN biz_key VARCHAR(128) NULL COMMENT '幂等业务键' AFTER source, + ADD UNIQUE KEY uk_renter_growth_biz_key (biz_key), + ADD UNIQUE KEY uk_renter_growth_order_role_source (order_id, source, role), + ADD KEY idx_renter_growth_user_role_created (user_id, role, created_at); + +ALTER TABLE rental_orders + ADD COLUMN growth_points_multiplier_bps INT NOT NULL DEFAULT 10000 COMMENT '租客积分倍率基点' AFTER growth_points_per_yuan, + ADD COLUMN owner_growth_points_basis_cent BIGINT NOT NULL DEFAULT 0 COMMENT '卖家积分基数(分)' AFTER growth_points_awarded_at, + ADD COLUMN owner_growth_points_per_yuan BIGINT NOT NULL DEFAULT 0 COMMENT '卖家基础每元积分' AFTER owner_growth_points_basis_cent, + ADD COLUMN owner_growth_points_multiplier_bps INT NOT NULL DEFAULT 10000 COMMENT '卖家积分倍率基点' AFTER owner_growth_points_per_yuan, + ADD COLUMN owner_growth_points_awarded BIGINT NOT NULL DEFAULT 0 COMMENT '卖家已发放积分' AFTER owner_growth_points_multiplier_bps, + ADD COLUMN owner_growth_points_awarded_at DATETIME NULL COMMENT '卖家积分发放时间' AFTER owner_growth_points_awarded; + +CREATE TABLE IF NOT EXISTS points_mall_products ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(128) NOT NULL, + subtitle VARCHAR(255) NOT NULL DEFAULT '', + description TEXT NOT NULL, + cover_url VARCHAR(512) NOT NULL DEFAULT '', + redemption_instructions TEXT NOT NULL, + points_price BIGINT NOT NULL DEFAULT 0, + stock BIGINT NOT NULL DEFAULT 0, + redeemed_count BIGINT NOT NULL DEFAULT 0, + per_user_limit INT NOT NULL DEFAULT 1, + status VARCHAR(16) NOT NULL DEFAULT 'offline', + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + KEY idx_points_mall_products_status_sort (status, sort_order, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分商城虚拟商品'; + +CREATE TABLE IF NOT EXISTS points_mall_redemptions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + redemption_no VARCHAR(32) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + product_id BIGINT UNSIGNED NOT NULL, + product_title VARCHAR(128) NOT NULL, + product_cover_url VARCHAR(512) NOT NULL DEFAULT '', + redemption_instructions TEXT NOT NULL, + points_price BIGINT NOT NULL DEFAULT 0, + quantity INT NOT NULL DEFAULT 1, + total_points BIGINT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + admin_remark VARCHAR(255) NOT NULL DEFAULT '', + completed_by BIGINT UNSIGNED NULL, + completed_at DATETIME NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (id), + UNIQUE KEY uk_points_mall_redemption_no (redemption_no), + KEY idx_points_mall_redemptions_user (user_id, created_at), + KEY idx_points_mall_redemptions_status (status, created_at), + KEY idx_points_mall_redemptions_product (product_id, user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分商城兑换记录'; + +INSERT INTO permissions (code, name, resource, action) +VALUES + ('points_mall:view', '查看积分商城', 'points_mall', 'view'), + ('points_mall:manage', '管理积分商城', 'points_mall', 'manage') +ON DUPLICATE KEY UPDATE name = VALUES(name), resource = VALUES(resource), action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.code IN ('points_mall:view', 'points_mall:manage') +WHERE r.code IN ('super_admin', 'ops'); + +-- +goose Down +DELETE FROM role_permissions +WHERE permission_id IN ( + SELECT id FROM permissions WHERE code IN ('points_mall:view', 'points_mall:manage') +); +DELETE FROM permissions WHERE code IN ('points_mall:view', 'points_mall:manage'); +DROP TABLE IF EXISTS points_mall_redemptions; +DROP TABLE IF EXISTS points_mall_products; + +ALTER TABLE rental_orders + DROP COLUMN owner_growth_points_awarded_at, + DROP COLUMN owner_growth_points_awarded, + DROP COLUMN owner_growth_points_multiplier_bps, + DROP COLUMN owner_growth_points_per_yuan, + DROP COLUMN owner_growth_points_basis_cent, + DROP COLUMN growth_points_multiplier_bps; + +ALTER TABLE renter_growth_ledger + DROP KEY idx_renter_growth_user_role_created, + DROP KEY uk_renter_growth_order_role_source, + DROP KEY uk_renter_growth_biz_key, + DROP COLUMN biz_key, + DROP COLUMN redemption_id, + DROP COLUMN available_after, + DROP COLUMN available_before, + DROP COLUMN points_multiplier_bps, + DROP COLUMN role, + ADD UNIQUE KEY uk_renter_growth_order_source (order_id, source); + +ALTER TABLE users + DROP COLUMN points_available; diff --git a/frontend/src/features/admin/api/adminUsers.ts b/frontend/src/features/admin/api/adminUsers.ts index 801a878..efd16ab 100644 --- a/frontend/src/features/admin/api/adminUsers.ts +++ b/frontend/src/features/admin/api/adminUsers.ts @@ -17,6 +17,7 @@ export interface AdminUserItem { deposit_free_used_cent: number deposit_free_remaining_cent: number renter_growth_points: number + points_available: number renter_growth_level: string renter_growth_level_name: string available_balance_cent: number diff --git a/frontend/src/features/admin/api/renterGrowth.ts b/frontend/src/features/admin/api/renterGrowth.ts index bbe8fd8..70f2b42 100644 --- a/frontend/src/features/admin/api/renterGrowth.ts +++ b/frontend/src/features/admin/api/renterGrowth.ts @@ -2,6 +2,7 @@ export interface RenterGrowthLevelRule { code: 'normal' | 'platinum' | 'diamond' | 'peak' name: string min_points: number + points_multiplier_bps: number discount_bps: number deposit_free_quota_cent: number } @@ -20,6 +21,7 @@ export const defaultRenterGrowthConfig: RenterGrowthConfig = { code: 'normal', name: '普通', min_points: 0, + points_multiplier_bps: 10000, discount_bps: 10000, deposit_free_quota_cent: 0, }, @@ -27,6 +29,7 @@ export const defaultRenterGrowthConfig: RenterGrowthConfig = { code: 'platinum', name: '铂金', min_points: 300, + points_multiplier_bps: 12000, discount_bps: 9900, deposit_free_quota_cent: 50000, }, @@ -34,6 +37,7 @@ export const defaultRenterGrowthConfig: RenterGrowthConfig = { code: 'diamond', name: '钻石', min_points: 1000, + points_multiplier_bps: 15000, discount_bps: 9800, deposit_free_quota_cent: 100000, }, @@ -41,6 +45,7 @@ export const defaultRenterGrowthConfig: RenterGrowthConfig = { code: 'peak', name: '巅峰', min_points: 5000, + points_multiplier_bps: 20000, discount_bps: 9500, deposit_free_quota_cent: 150000, }, @@ -59,6 +64,10 @@ export function parseRenterGrowthConfig(raw: string): RenterGrowthConfig { code: defaultLevel.code, name: defaultLevel.name, min_points: nonNegativeInteger(incoming?.min_points, defaultLevel.min_points), + points_multiplier_bps: positiveInteger( + incoming?.points_multiplier_bps, + defaultLevel.points_multiplier_bps + ), discount_bps: positiveInteger(incoming?.discount_bps, defaultLevel.discount_bps), deposit_free_quota_cent: nonNegativeInteger( incoming?.deposit_free_quota_cent, diff --git a/frontend/src/features/admin/components/RenterGrowthConfigDialog.vue b/frontend/src/features/admin/components/RenterGrowthConfigDialog.vue index 509a461..b4aeaef 100644 --- a/frontend/src/features/admin/components/RenterGrowthConfigDialog.vue +++ b/frontend/src/features/admin/components/RenterGrowthConfigDialog.vue @@ -49,6 +49,14 @@ function updateQuotaYuan(level: RenterGrowthLevelRule, value: number | undefined level.deposit_free_quota_cent = Math.round(Number(value || 0) * 100) } +function pointsMultiplier(level: RenterGrowthLevelRule) { + return level.points_multiplier_bps / 10000 +} + +function updatePointsMultiplier(level: RenterGrowthLevelRule, value: number | undefined) { + level.points_multiplier_bps = Math.round(Number(value || 0) * 10000) +} + function resetDefaults() { draft.value = cloneRenterGrowthConfig(defaultRenterGrowthConfig) } @@ -64,6 +72,16 @@ function validateDraft() { } if (index === 0 && level.min_points !== 0) return '普通等级积分门槛必须为 0' if (previous && level.min_points <= previous.min_points) return '等级积分门槛必须严格递增' + if ( + !Number.isInteger(level.points_multiplier_bps) || + level.points_multiplier_bps <= 0 || + level.points_multiplier_bps > 100000 + ) { + return `${level.name}积分倍率不正确` + } + if (previous && level.points_multiplier_bps < previous.points_multiplier_bps) { + return '高等级积分倍率不能低于低等级' + } if (level.discount_bps <= 0 || level.discount_bps > 10000) { return `${level.name}支付比例必须在 0% 到 100% 之间` } @@ -89,7 +107,7 @@ async function handleSave() { try { await updateSystemConfig(props.config.key, { value: JSON.stringify(draft.value), - description: '租客成长等级规则(积分门槛、租金折扣、等级免押额度)', + description: '积分等级规则(积分门槛、积分倍率、租金折扣、等级免押额度)', }) ElMessage.success('成长等级配置已更新') emit('saved') @@ -105,14 +123,14 @@ async function handleSave() { + + +