feat(points): 增加双边积分与虚拟积分商城
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 是“我的订单”列表的最小展示数据,详情数据仅由单订单接口返回。
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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", "积分商城服务暂时不可用")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="成长等级配置"
|
||||
title="积分等级配置"
|
||||
width="860px"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="growth-config-form">
|
||||
<div class="growth-switch-row">
|
||||
<span>启用成长等级</span>
|
||||
<span>启用积分等级</span>
|
||||
<el-switch v-model="draft.enabled" />
|
||||
</div>
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
@@ -141,6 +159,19 @@ async function handleSave() {
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="积分倍率" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
:model-value="pointsMultiplier(row)"
|
||||
:min="0.1"
|
||||
:max="10"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
@update:model-value="updatePointsMultiplier(row, $event)"
|
||||
/>
|
||||
<span class="unit">倍</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="租金支付比例" min-width="190">
|
||||
<template #default="{ row, $index }">
|
||||
<el-input-number
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import {
|
||||
completePointsRedemption,
|
||||
createPointsProduct,
|
||||
fetchAdminPointsProducts,
|
||||
fetchAdminPointsRedemptions,
|
||||
updatePointsProduct,
|
||||
type PointsMallProduct,
|
||||
type PointsMallProductPayload,
|
||||
type PointsMallRedemption,
|
||||
} from '@/features/points/api/pointsMall'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
|
||||
const adminSession = useAdminSessionStore()
|
||||
const activeTab = ref<'products' | 'redemptions'>('products')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const products = ref<PointsMallProduct[]>([])
|
||||
const redemptions = ref<PointsMallRedemption[]>([])
|
||||
const editorVisible = ref(false)
|
||||
const editingID = ref(0)
|
||||
const canManage = computed(() => adminSession.hasPermission('points_mall:manage'))
|
||||
const form = reactive<PointsMallProductPayload>(defaultProduct())
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [nextProducts, nextRedemptions] = await Promise.all([
|
||||
fetchAdminPointsProducts(),
|
||||
fetchAdminPointsRedemptions(),
|
||||
])
|
||||
products.value = nextProducts
|
||||
redemptions.value = nextRedemptions
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '积分商城数据加载失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingID.value = 0
|
||||
Object.assign(form, defaultProduct())
|
||||
editorVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: PointsMallProduct) {
|
||||
editingID.value = item.id
|
||||
Object.assign(form, {
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
description: item.description,
|
||||
cover_url: item.cover_url,
|
||||
redemption_instructions: item.redemption_instructions,
|
||||
points_price: item.points_price,
|
||||
stock: item.stock,
|
||||
per_user_limit: item.per_user_limit,
|
||||
status: item.status === 'online' ? 'online' : 'offline',
|
||||
sort_order: item.sort_order,
|
||||
})
|
||||
editorVisible.value = true
|
||||
}
|
||||
|
||||
async function saveProduct() {
|
||||
if (!form.title.trim() || !form.description.trim() || !form.redemption_instructions.trim()) {
|
||||
ElMessage.warning('请填写商品名称、说明和兑换说明')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = { ...form }
|
||||
if (editingID.value) {
|
||||
await updatePointsProduct(editingID.value, payload)
|
||||
} else {
|
||||
await createPointsProduct(payload)
|
||||
}
|
||||
ElMessage.success('积分商品已保存')
|
||||
editorVisible.value = false
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '积分商品保存失败'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function completeRedemption(item: PointsMallRedemption) {
|
||||
const remark = window.prompt('填写发放说明或处理备注(可留空)', item.admin_remark || '')
|
||||
if (remark === null) return
|
||||
try {
|
||||
await completePointsRedemption(item.id, remark)
|
||||
ElMessage.success('兑换已标记完成')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '处理兑换失败'))
|
||||
}
|
||||
}
|
||||
|
||||
function defaultProduct(): PointsMallProductPayload {
|
||||
return {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
description: '',
|
||||
cover_url: '',
|
||||
redemption_instructions: '',
|
||||
points_price: 100,
|
||||
stock: 10,
|
||||
per_user_limit: 1,
|
||||
status: 'offline',
|
||||
sort_order: 0,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="admin-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>积分商城</h1>
|
||||
<p>管理虚拟商品、库存和用户兑换发放。</p>
|
||||
</div>
|
||||
<el-button v-if="canManage" type="primary" @click="openCreate">新增积分商品</el-button>
|
||||
</header>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="商品管理" name="products">
|
||||
<el-table v-loading="loading" :data="products" border>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="title" label="商品" min-width="180" />
|
||||
<el-table-column prop="points_price" label="积分价格" width="110" />
|
||||
<el-table-column prop="stock" label="库存" width="90" />
|
||||
<el-table-column prop="redeemed_count" label="已兑换" width="90" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'online' ? 'success' : 'info'">
|
||||
{{ row.status === 'online' ? '上架' : '下架' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" link type="primary" @click="openEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="兑换记录" name="redemptions">
|
||||
<el-table v-loading="loading" :data="redemptions" border>
|
||||
<el-table-column prop="redemption_no" label="兑换单号" min-width="190" />
|
||||
<el-table-column label="用户" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.user_nickname || '-' }}<br />
|
||||
<small>{{ row.user_phone || row.user_id }}</small>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="product_title" label="商品" min-width="160" />
|
||||
<el-table-column prop="total_points" label="积分" width="100" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'completed' ? 'success' : 'warning'">
|
||||
{{ row.status === 'completed' ? '已发放' : '待处理' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="兑换时间" min-width="170" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="canManage && row.status === 'pending'"
|
||||
link
|
||||
type="primary"
|
||||
@click="completeRedemption(row)"
|
||||
>
|
||||
发放
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog
|
||||
v-model="editorVisible"
|
||||
:title="editingID ? '编辑积分商品' : '新增积分商品'"
|
||||
width="680px"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="商品名称">
|
||||
<el-input v-model="form.title" maxlength="128" />
|
||||
</el-form-item>
|
||||
<el-form-item label="副标题">
|
||||
<el-input v-model="form.subtitle" maxlength="255" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品说明">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="封面图片 URL">
|
||||
<el-input v-model="form.cover_url" />
|
||||
</el-form-item>
|
||||
<el-form-item label="兑换后发放说明">
|
||||
<el-input v-model="form.redemption_instructions" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="积分价格">
|
||||
<el-input-number v-model="form.points_price" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="库存">
|
||||
<el-input-number v-model="form.stock" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="每人限兑">
|
||||
<el-input-number v-model="form.per_user_limit" :min="1" :max="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort_order" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio-button value="online">上架</el-radio-button>
|
||||
<el-radio-button value="offline">下架</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editorVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="saveProduct">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-page {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 6px 0 0;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -695,10 +695,10 @@ function formatConfigValue(row: SystemConfig) {
|
||||
<div class="publish-config-main">
|
||||
<div>
|
||||
<p class="eyebrow">Renter Growth</p>
|
||||
<h2>租客成长等级</h2>
|
||||
<span>管理完成订单后的积分换算、各等级租金支付比例与等级免押额度。</span>
|
||||
<h2>积分等级规则</h2>
|
||||
<span>管理订单积分倍率、等级门槛、租金支付比例与等级免押额度。</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="openEdit(renterGrowthConfig)">编辑成长等级</el-button>
|
||||
<el-button type="primary" @click="openEdit(renterGrowthConfig)">编辑积分等级</el-button>
|
||||
</div>
|
||||
<div class="publish-stat-grid home-stat-grid">
|
||||
<div class="publish-stat">
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface AuthUser {
|
||||
credit_score: number
|
||||
renter_growth_points?: number
|
||||
renter_growth_level?: string
|
||||
points_available?: number
|
||||
quality_owner_status?: string
|
||||
status: UserStatus
|
||||
}
|
||||
|
||||
@@ -684,6 +684,39 @@
|
||||
background: linear-gradient(135deg, #faf8ff 0%, #ffffff 72%);
|
||||
}
|
||||
|
||||
.mobile-points-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 10px 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 14px;
|
||||
background: #faf8ff;
|
||||
}
|
||||
|
||||
.mobile-points-entry div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.mobile-points-entry span {
|
||||
color: #7c3aed;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.mobile-points-entry strong {
|
||||
color: #312e81;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.mobile-points-entry small {
|
||||
color: #6b7280;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.quality-owner-card.is-active {
|
||||
border-color: #c4b5fd;
|
||||
}
|
||||
|
||||
@@ -525,7 +525,7 @@ function resolveAvatarURL(url: string | undefined | null) {
|
||||
<strong>{{ session.renterGrowthLevelName }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>当前积分</span>
|
||||
<span>累计积分</span>
|
||||
<strong>{{ session.renterGrowthPoints }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
@@ -605,6 +605,15 @@ function resolveAvatarURL(url: string | undefined | null) {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mobile-points-entry" @click="router.push('/m/points-mall')">
|
||||
<div>
|
||||
<span>积分商城</span>
|
||||
<strong>{{ session.pointsAvailable }} 可用积分</strong>
|
||||
<small>{{ session.renterGrowthLevelName }}等级</small>
|
||||
</div>
|
||||
<van-icon name="arrow" />
|
||||
</section>
|
||||
|
||||
<!-- 余额卡片 - 银行卡质感暗色卡片 -->
|
||||
<div class="balance-card">
|
||||
<div class="balance-row">
|
||||
|
||||
@@ -420,7 +420,7 @@ async function savePassword() {
|
||||
<dd class="growth-level">{{ session.renterGrowthLevelName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>成长积分</dt>
|
||||
<dt>累计积分</dt>
|
||||
<dd>{{ session.renterGrowthPoints }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -514,6 +514,17 @@ async function savePassword() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="points-entry-card">
|
||||
<div>
|
||||
<span>POINTS MALL</span>
|
||||
<strong>积分商城</strong>
|
||||
<p>
|
||||
可用 {{ session.pointsAvailable }} 积分 · 当前等级 {{ session.renterGrowthLevelName }}
|
||||
</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="router.push('/points-mall')">进入积分商城</el-button>
|
||||
</section>
|
||||
|
||||
<PayWaySelectDialog v-model="qualityOwnerPayWayVisible" @choose="chooseQualityOwnerPayWay" />
|
||||
|
||||
<el-dialog
|
||||
@@ -1023,6 +1034,45 @@ async function savePassword() {
|
||||
box-shadow: 0 10px 28px rgba(76, 29, 149, 0.06);
|
||||
}
|
||||
|
||||
.points-entry-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
padding: 20px 24px;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #faf8ff 0%, #fff 72%);
|
||||
}
|
||||
|
||||
.points-entry-card div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.points-entry-card span {
|
||||
color: #7c3aed;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.points-entry-card strong {
|
||||
color: #312e81;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.points-entry-card p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.points-entry-card :deep(.el-button--primary) {
|
||||
border: none;
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
.quality-owner-card.is-active {
|
||||
border-color: #c4b5fd;
|
||||
}
|
||||
|
||||
@@ -45,8 +45,14 @@ export interface Order {
|
||||
renter_discount_bps?: number
|
||||
growth_points_basis_cent?: number
|
||||
growth_points_per_yuan?: number
|
||||
growth_points_multiplier_bps?: number
|
||||
growth_points_awarded?: number
|
||||
growth_points_awarded_at?: string
|
||||
owner_growth_points_basis_cent?: number
|
||||
owner_growth_points_per_yuan?: number
|
||||
owner_growth_points_multiplier_bps?: number
|
||||
owner_growth_points_awarded?: number
|
||||
owner_growth_points_awarded_at?: string
|
||||
account_snapshot?: Record<string, unknown>
|
||||
account_source?: 'internal' | 'external' | string
|
||||
source_channel?: string
|
||||
|
||||
@@ -20,6 +20,12 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const checkout = computed(() => props.order.checkout)
|
||||
const pointsAwarded = computed(() =>
|
||||
props.isOwner ? props.order.owner_growth_points_awarded : props.order.growth_points_awarded
|
||||
)
|
||||
const pointsBasisCent = computed(() =>
|
||||
props.isOwner ? props.order.owner_growth_points_basis_cent : props.order.growth_points_basis_cent
|
||||
)
|
||||
// 结账内容含手填备注与系统结算信息,拆开展示并标注备注不参与结算
|
||||
const checkoutContent = computed(() => splitCheckoutContent(props.order.checkout?.content))
|
||||
</script>
|
||||
@@ -105,10 +111,10 @@ const checkoutContent = computed(() => splitCheckoutContent(props.order.checkout
|
||||
value-class="green-text"
|
||||
/>
|
||||
<van-cell
|
||||
v-if="props.isRenter && props.order.status === 'completed'"
|
||||
title="本单成长积分"
|
||||
:label="`积分基数 ¥${money(amountYuan(props.order.growth_points_basis_cent))}`"
|
||||
:value="`+${Number(props.order.growth_points_awarded || 0)}`"
|
||||
v-if="(props.isRenter || props.isOwner) && props.order.status === 'completed'"
|
||||
:title="props.isOwner ? '本单卖家积分' : '本单租客积分'"
|
||||
:label="`积分基数 ¥${money(amountYuan(pointsBasisCent))}`"
|
||||
:value="`+${Number(pointsAwarded || 0)}`"
|
||||
value-class="green-text"
|
||||
/>
|
||||
<van-cell
|
||||
|
||||
@@ -297,9 +297,13 @@ async function copyListingCode() {
|
||||
>
|
||||
</div>
|
||||
<div v-if="isRenter && order.status === 'completed' && !order.checkout" class="meta-item">
|
||||
<span class="meta-label">本单成长积分</span>
|
||||
<span class="meta-label">本单租客积分</span>
|
||||
<strong class="meta-value income">+{{ order.growth_points_awarded || 0 }}</strong>
|
||||
</div>
|
||||
<div v-if="isOwner && order.status === 'completed' && !order.checkout" class="meta-item">
|
||||
<span class="meta-label">本单卖家积分</span>
|
||||
<strong class="meta-value income">+{{ order.owner_growth_points_awarded || 0 }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -457,9 +457,16 @@ watch([() => route.query.focus, order, loading], () => {
|
||||
v-if="isRenter && order.status === 'completed' && !order.checkout"
|
||||
class="metric-card income"
|
||||
>
|
||||
<span class="metric-label">本单成长积分</span>
|
||||
<span class="metric-label">本单租客积分</span>
|
||||
<strong class="metric-value">+{{ order.growth_points_awarded || 0 }}</strong>
|
||||
</div>
|
||||
<div
|
||||
v-if="isOwner && order.status === 'completed' && !order.checkout"
|
||||
class="metric-card income"
|
||||
>
|
||||
<span class="metric-label">本单卖家积分</span>
|
||||
<strong class="metric-value">+{{ order.owner_growth_points_awarded || 0 }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="order" class="info-section">
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { ApiResponse } from '@/shared/types/types'
|
||||
|
||||
export interface PointsSummary {
|
||||
available_points: number
|
||||
lifetime_points: number
|
||||
level_code: string
|
||||
level_name: string
|
||||
points_per_yuan: number
|
||||
points_multiplier_bps: number
|
||||
}
|
||||
|
||||
export interface PointsLedgerItem {
|
||||
id: number
|
||||
role: string
|
||||
points: number
|
||||
basis_amount_cent: number
|
||||
before_points: number
|
||||
after_points: number
|
||||
available_before: number
|
||||
available_after: number
|
||||
source: string
|
||||
remark: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PointsMallProduct {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string
|
||||
description: string
|
||||
cover_url: string
|
||||
redemption_instructions: string
|
||||
points_price: number
|
||||
stock: number
|
||||
redeemed_count: number
|
||||
per_user_limit: number
|
||||
status: 'online' | 'offline' | string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface PointsMallRedemption {
|
||||
id: number
|
||||
redemption_no: string
|
||||
user_id: number
|
||||
user_phone?: string
|
||||
user_nickname?: string
|
||||
product_id: number
|
||||
product_title: string
|
||||
product_cover_url: string
|
||||
redemption_instructions: string
|
||||
points_price: number
|
||||
quantity: number
|
||||
total_points: number
|
||||
status: 'pending' | 'completed' | string
|
||||
admin_remark: string
|
||||
completed_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PointsMallProductPayload {
|
||||
title: string
|
||||
subtitle: string
|
||||
description: string
|
||||
cover_url: string
|
||||
redemption_instructions: string
|
||||
points_price: number
|
||||
stock: number
|
||||
per_user_limit: number
|
||||
status: 'online' | 'offline'
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export async function fetchPointsSummary() {
|
||||
const { data } = await apiClient.get<ApiResponse<PointsSummary>>('/points/summary')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchPointsLedger() {
|
||||
const { data } = await apiClient.get<ApiResponse<PointsLedgerItem[]>>('/points/ledger')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchPointsProducts() {
|
||||
const { data } = await apiClient.get<ApiResponse<PointsMallProduct[]>>('/points-mall/products')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function redeemPointsProduct(id: number, quantity = 1) {
|
||||
const { data } = await apiClient.post<ApiResponse<PointsMallRedemption>>(
|
||||
`/points-mall/products/${id}/redeem`,
|
||||
{ quantity }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchMyPointsRedemptions() {
|
||||
const { data } = await apiClient.get<ApiResponse<PointsMallRedemption[]>>(
|
||||
'/points-mall/redemptions'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminPointsProducts() {
|
||||
const { data } = await apiClient.get<ApiResponse<PointsMallProduct[]>>(
|
||||
'/admin/points-mall/products'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createPointsProduct(payload: PointsMallProductPayload) {
|
||||
const { data } = await apiClient.post<ApiResponse<PointsMallProduct>>(
|
||||
'/admin/points-mall/products',
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updatePointsProduct(id: number, payload: PointsMallProductPayload) {
|
||||
const { data } = await apiClient.put<ApiResponse<PointsMallProduct>>(
|
||||
`/admin/points-mall/products/${id}`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminPointsRedemptions() {
|
||||
const { data } = await apiClient.get<ApiResponse<PointsMallRedemption[]>>(
|
||||
'/admin/points-mall/redemptions'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function completePointsRedemption(id: number, remark: string) {
|
||||
const { data } = await apiClient.post<ApiResponse<PointsMallRedemption>>(
|
||||
`/admin/points-mall/redemptions/${id}/complete`,
|
||||
{ remark }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
fetchMyPointsRedemptions,
|
||||
fetchPointsLedger,
|
||||
fetchPointsProducts,
|
||||
fetchPointsSummary,
|
||||
redeemPointsProduct,
|
||||
type PointsLedgerItem,
|
||||
type PointsMallProduct,
|
||||
type PointsMallRedemption,
|
||||
type PointsSummary,
|
||||
} from '@/features/points/api/pointsMall'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const redeemingID = ref(0)
|
||||
const errorMessage = ref('')
|
||||
const summary = ref<PointsSummary | null>(null)
|
||||
const products = ref<PointsMallProduct[]>([])
|
||||
const redemptions = ref<PointsMallRedemption[]>([])
|
||||
const ledger = ref<PointsLedgerItem[]>([])
|
||||
const activeTab = ref<'mall' | 'records'>('mall')
|
||||
const isMobile = computed(() => route.path.startsWith('/m/'))
|
||||
const multiplierText = computed(() =>
|
||||
summary.value ? (summary.value.points_multiplier_bps / 10000).toFixed(2) : '1.00'
|
||||
)
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const [nextSummary, nextProducts, nextRedemptions, nextLedger] = await Promise.all([
|
||||
fetchPointsSummary(),
|
||||
fetchPointsProducts(),
|
||||
fetchMyPointsRedemptions(),
|
||||
fetchPointsLedger(),
|
||||
])
|
||||
summary.value = nextSummary
|
||||
products.value = nextProducts
|
||||
redemptions.value = nextRedemptions
|
||||
ledger.value = nextLedger
|
||||
} catch (error) {
|
||||
errorMessage.value = readError(error, '积分商城加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRedeem(product: PointsMallProduct) {
|
||||
if (!summary.value || product.stock <= 0 || summary.value.available_points < product.points_price)
|
||||
return
|
||||
if (!window.confirm(`确认使用 ${product.points_price} 积分兑换「${product.title}」?`)) return
|
||||
redeemingID.value = product.id
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await redeemPointsProduct(product.id)
|
||||
await load()
|
||||
activeTab.value = 'records'
|
||||
} catch (error) {
|
||||
errorMessage.value = readError(error, '兑换失败')
|
||||
} finally {
|
||||
redeemingID.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function back() {
|
||||
router.push(isMobile.value ? '/m/profile' : '/profile')
|
||||
}
|
||||
|
||||
function sourceLabel(source: string) {
|
||||
const labels: Record<string, string> = {
|
||||
order_completed: '订单完成',
|
||||
order_refunded: '退款冲正',
|
||||
admin_adjustment: '后台调整',
|
||||
mall_redeem: '商城兑换',
|
||||
}
|
||||
return labels[source] || source
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="points-mall" :class="{ 'is-mobile': isMobile }">
|
||||
<header class="points-header">
|
||||
<button type="button" class="back-button" @click="back">返回</button>
|
||||
<div>
|
||||
<span>POINTS CENTER</span>
|
||||
<h1>积分商城</h1>
|
||||
<p>租客按实际租金支出、卖家按实际租金收入获得积分。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="errorMessage" class="notice is-error">{{ errorMessage }}</p>
|
||||
|
||||
<section v-if="summary" class="points-overview">
|
||||
<div class="points-balance">
|
||||
<span>可用积分</span>
|
||||
<strong>{{ summary.available_points }}</strong>
|
||||
<small>累计 {{ summary.lifetime_points }} 积分,不随兑换降级</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>当前等级</span>
|
||||
<strong>{{ summary.level_name }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>积分倍率</span>
|
||||
<strong>{{ multiplierText }}x</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>基础规则</span>
|
||||
<strong>¥1 = {{ summary.points_per_yuan }} 积分</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="points-tabs">
|
||||
<button type="button" :class="{ active: activeTab === 'mall' }" @click="activeTab = 'mall'">
|
||||
积分商品
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: activeTab === 'records' }"
|
||||
@click="activeTab = 'records'"
|
||||
>
|
||||
我的兑换
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<p v-if="loading" class="notice">加载中...</p>
|
||||
|
||||
<section v-else-if="activeTab === 'mall'" class="product-section">
|
||||
<div v-if="!products.length" class="empty-state">暂无可兑换商品</div>
|
||||
<article v-for="product in products" :key="product.id" class="product-card">
|
||||
<div class="product-cover">
|
||||
<img v-if="product.cover_url" :src="product.cover_url" :alt="product.title" />
|
||||
<span v-else>{{ product.title.slice(0, 1) }}</span>
|
||||
</div>
|
||||
<div class="product-copy">
|
||||
<span v-if="product.subtitle">{{ product.subtitle }}</span>
|
||||
<h2>{{ product.title }}</h2>
|
||||
<p>{{ product.description }}</p>
|
||||
<small>库存 {{ product.stock }} · 每人限兑 {{ product.per_user_limit }} 件</small>
|
||||
</div>
|
||||
<div class="product-action">
|
||||
<strong>{{ product.points_price }} 积分</strong>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="
|
||||
product.stock <= 0 ||
|
||||
!summary ||
|
||||
summary.available_points < product.points_price ||
|
||||
redeemingID === product.id
|
||||
"
|
||||
@click="handleRedeem(product)"
|
||||
>
|
||||
{{ redeemingID === product.id ? '兑换中' : product.stock <= 0 ? '已兑完' : '立即兑换' }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-else class="record-section">
|
||||
<div v-if="!redemptions.length" class="empty-state">还没有兑换记录</div>
|
||||
<article v-for="item in redemptions" :key="item.id" class="record-card">
|
||||
<div>
|
||||
<span>{{ item.redemption_no }}</span>
|
||||
<strong>{{ item.product_title }}</strong>
|
||||
<small>{{ new Date(item.created_at).toLocaleString() }}</small>
|
||||
</div>
|
||||
<div class="record-status">
|
||||
<strong>-{{ item.total_points }}</strong>
|
||||
<span :class="{ completed: item.status === 'completed' }">
|
||||
{{ item.status === 'completed' ? '已发放' : '待处理' }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="item.status === 'completed'">{{ item.redemption_instructions }}</p>
|
||||
<p v-if="item.admin_remark">处理备注:{{ item.admin_remark }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-if="ledger.length" class="ledger-section">
|
||||
<h2>最近积分明细</h2>
|
||||
<div v-for="item in ledger.slice(0, 8)" :key="item.id" class="ledger-row">
|
||||
<div>
|
||||
<strong>{{ item.remark || sourceLabel(item.source) }}</strong>
|
||||
<span>{{ new Date(item.created_at).toLocaleString() }}</span>
|
||||
</div>
|
||||
<em :class="{ income: item.points > 0 }">
|
||||
{{ item.points > 0 ? '+' : '' }}{{ item.points }}
|
||||
</em>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.points-mall {
|
||||
min-height: 100vh;
|
||||
padding: 28px max(20px, calc((100vw - 1240px) / 2));
|
||||
background: #f5f7fb;
|
||||
color: #20242a;
|
||||
}
|
||||
|
||||
.points-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.points-header span,
|
||||
.product-copy > span {
|
||||
color: #7c3aed;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.points-header h1 {
|
||||
margin: 4px 0;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.points-header p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
min-height: 34px;
|
||||
padding: 0 13px;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 17px;
|
||||
background: #fff;
|
||||
color: #6d28d9;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.points-overview {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.points-overview > div,
|
||||
.product-card,
|
||||
.record-card,
|
||||
.ledger-section {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.points-overview > div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.points-overview span {
|
||||
color: #7b8492;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.points-overview strong {
|
||||
color: #312e81;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.points-overview small {
|
||||
color: #8b95a5;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.points-balance {
|
||||
border-color: #c4b5fd !important;
|
||||
background: #faf8ff !important;
|
||||
}
|
||||
|
||||
.points-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 22px 0 14px;
|
||||
}
|
||||
|
||||
.points-tabs button {
|
||||
min-height: 36px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
color: #6b7280;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.points-tabs button.active {
|
||||
border-color: #7c3aed;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.product-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
display: grid;
|
||||
grid-template-rows: 150px 1fr auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-cover {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #f1ebff;
|
||||
color: #6d28d9;
|
||||
font-size: 42px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.product-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.product-copy {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 7px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.product-copy h2,
|
||||
.ledger-section h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.product-copy p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.product-copy small {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.product-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 13px 15px;
|
||||
border-top: 1px solid #f0f1f4;
|
||||
}
|
||||
|
||||
.product-action strong {
|
||||
color: #7c3aed;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.product-action button {
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 17px;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.product-action button:disabled {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.record-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.record-card > div:first-child {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.record-card span,
|
||||
.record-card small {
|
||||
color: #8b95a5;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.record-status {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.record-status strong {
|
||||
display: block;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.record-status span {
|
||||
display: inline-block;
|
||||
margin-top: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.record-status span.completed {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.record-card p {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ledger-section {
|
||||
margin-top: 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ledger-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 0;
|
||||
border-top: 1px solid #f1f2f5;
|
||||
}
|
||||
|
||||
.ledger-row div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ledger-row span {
|
||||
color: #9ca3af;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ledger-row em {
|
||||
color: #dc2626;
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.ledger-row em.income {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.notice,
|
||||
.empty-state {
|
||||
margin: 12px 0;
|
||||
padding: 18px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notice.is-error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.points-mall.is-mobile {
|
||||
padding: 18px 12px 90px;
|
||||
}
|
||||
|
||||
.is-mobile .points-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.is-mobile .points-overview {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.is-mobile .points-balance {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.is-mobile .points-overview > div {
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.is-mobile .points-overview strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.is-mobile .product-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.is-mobile .product-card {
|
||||
grid-template-columns: 104px minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
.is-mobile .product-cover {
|
||||
grid-row: 1 / 3;
|
||||
min-height: 144px;
|
||||
}
|
||||
|
||||
.is-mobile .product-copy {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.is-mobile .product-action {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -89,6 +89,12 @@ const allNavGroups: NavGroup[] = [
|
||||
icon: Briefcase,
|
||||
children: [
|
||||
{ label: '用户管理', to: adminPath('users'), icon: User, permission: 'user:view' },
|
||||
{
|
||||
label: '积分商城',
|
||||
to: adminPath('points-mall'),
|
||||
icon: Present,
|
||||
permission: 'points_mall:view',
|
||||
},
|
||||
{ label: '订单管理', to: adminPath('orders'), icon: Tickets, permission: 'order:view' },
|
||||
{
|
||||
label: '退款审核',
|
||||
|
||||
@@ -272,7 +272,7 @@ async function handleSupportClick() {
|
||||
<strong>{{ session.renterGrowthLevelName }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>当前积分</span>
|
||||
<span>累计积分</span>
|
||||
<strong>{{ session.renterGrowthPoints }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,12 @@ export const accountRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/features/auth/views/ProfileView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/points-mall',
|
||||
name: 'points-mall',
|
||||
component: () => import('@/features/points/views/PointsMallView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/orders',
|
||||
name: 'orders',
|
||||
|
||||
@@ -24,6 +24,12 @@ export const adminRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/features/admin/views/AdminUsersView.vue'),
|
||||
meta: adminMeta,
|
||||
},
|
||||
{
|
||||
path: adminPath('points-mall'),
|
||||
name: 'admin-points-mall',
|
||||
component: () => import('@/features/admin/views/AdminPointsMallView.vue'),
|
||||
meta: adminMeta,
|
||||
},
|
||||
{
|
||||
path: adminPath('orders'),
|
||||
name: 'admin-orders',
|
||||
|
||||
@@ -95,6 +95,12 @@ export const mobileRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/features/auth/views/MobileProfileView.vue'),
|
||||
meta: { layout: 'blank', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/m/points-mall',
|
||||
name: 'mobile-points-mall',
|
||||
component: () => import('@/features/points/views/PointsMallView.vue'),
|
||||
meta: { layout: 'blank', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/m/notifications',
|
||||
name: 'mobile-notifications',
|
||||
|
||||
@@ -17,6 +17,7 @@ const userKeys = {
|
||||
'avatar_url',
|
||||
'realname_status',
|
||||
'quality_owner_status',
|
||||
'points_available',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export const useSessionStore = defineStore('session', {
|
||||
avatarUrl: localStorage.getItem('avatar_url') || '',
|
||||
realnameStatus: localStorage.getItem('realname_status') || 'unknown',
|
||||
renterGrowthPoints: Number(localStorage.getItem('renter_growth_points') || 0),
|
||||
pointsAvailable: Number(localStorage.getItem('points_available') || 0),
|
||||
renterGrowthLevel: localStorage.getItem('renter_growth_level') || 'normal',
|
||||
qualityOwnerStatus: localStorage.getItem('quality_owner_status') || 'none',
|
||||
_loadingMe: false, // 防止重复调用
|
||||
@@ -105,6 +106,7 @@ export const useSessionStore = defineStore('session', {
|
||||
this.avatarUrl = ''
|
||||
this.realnameStatus = 'unknown'
|
||||
this.renterGrowthPoints = 0
|
||||
this.pointsAvailable = 0
|
||||
this.renterGrowthLevel = 'normal'
|
||||
this.qualityOwnerStatus = 'none'
|
||||
clearAuthStorage('user')
|
||||
@@ -118,6 +120,7 @@ export const useSessionStore = defineStore('session', {
|
||||
this.avatarUrl = localStorage.getItem('avatar_url') || ''
|
||||
this.realnameStatus = localStorage.getItem('realname_status') || 'unknown'
|
||||
this.renterGrowthPoints = Number(localStorage.getItem('renter_growth_points') || 0)
|
||||
this.pointsAvailable = Number(localStorage.getItem('points_available') || 0)
|
||||
this.renterGrowthLevel = localStorage.getItem('renter_growth_level') || 'normal'
|
||||
this.qualityOwnerStatus = localStorage.getItem('quality_owner_status') || 'none'
|
||||
},
|
||||
@@ -145,6 +148,8 @@ export const useSessionStore = defineStore('session', {
|
||||
localStorage.setItem('realname_status', user.realname_status)
|
||||
this.renterGrowthPoints = Number(user.renter_growth_points || 0)
|
||||
localStorage.setItem('renter_growth_points', String(this.renterGrowthPoints))
|
||||
this.pointsAvailable = Number(user.points_available || 0)
|
||||
localStorage.setItem('points_available', String(this.pointsAvailable))
|
||||
this.renterGrowthLevel = user.renter_growth_level || 'normal'
|
||||
localStorage.setItem('renter_growth_level', this.renterGrowthLevel)
|
||||
this.qualityOwnerStatus = user.quality_owner_status || 'none'
|
||||
|
||||
Reference in New Issue
Block a user