feat(points): 增加双边积分与虚拟积分商城

This commit is contained in:
yml2213
2026-11-04 19:21:02 +08:00
parent c00d691301
commit d5abe67f60
42 changed files with 2593 additions and 316 deletions
+47 -41
View File
@@ -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"`
+47
View File
@@ -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"
}
+20 -14
View File
@@ -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 {
+1
View File
@@ -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
}
}
+85 -79
View File
@@ -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 是“我的订单”列表的最小展示数据,详情数据仅由单订单接口返回。
+94 -76
View File
@@ -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, "")
}
}
+123
View File
@@ -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)
}
+197 -74
View File
@@ -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,
+22
View File
@@ -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)
+114
View File
@@ -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;