按实际纯币金额计算租客优惠与积分

This commit is contained in:
yml2213
2026-07-27 10:45:41 +08:00
parent 99e8eb28af
commit 8af7333969
30 changed files with 983 additions and 154 deletions
+7
View File
@@ -27,9 +27,16 @@ type RentalOrder struct {
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"`
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:"-"`
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"`
+3
View File
@@ -18,6 +18,9 @@ type OrderCheckout struct {
OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"`
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"`
DepositAmountCent int64 `gorm:"not null;default:0" json:"-"`
PureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"`
PureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"`
PureCoinPayableCent int64 `gorm:"not null;default:0" json:"-"`
ConsumableAmountCent int64 `gorm:"not null;default:0" json:"-"`
CoinConsumedM float64 `gorm:"type:decimal(12,2);not null;default:0" json:"coin_consumed_m"`
OtherAmountCent int64 `gorm:"not null;default:0" json:"-"`
+2
View File
@@ -8,6 +8,8 @@ type RenterGrowthLedger struct {
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"`
+33 -11
View File
@@ -12,6 +12,7 @@ import (
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/adminnotification"
"hfb_sys/backend/internal/modules/notification"
ordermodule "hfb_sys/backend/internal/modules/order"
"hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/internal/modules/wallet"
"hfb_sys/backend/pkg/money"
@@ -112,7 +113,23 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
return err
}
if order.Status == "completed" {
if err := rentergrowth.AwardOrderCompleted(tx, &order, arbitrationActualRentCent(order, settlement), rentergrowth.SourceOrderCompleted); err != nil {
coinConsumedM, linkedCheckout, err := arbitrationCoinConsumedM(tx, row, req)
if err != nil {
return err
}
pureCoin := ordermodule.CalculateActualPureCoinAmounts(order, coinConsumedM)
order.ActualCoinConsumedM = pureCoin.ConsumedM
order.ActualPureCoinAmountCent = pureCoin.AmountCent
order.ActualPureCoinDiscountCent = pureCoin.DiscountCent
if linkedCheckout != nil {
linkedCheckout.PureCoinAmountCent = pureCoin.AmountCent
linkedCheckout.PureCoinDiscountCent = pureCoin.DiscountCent
linkedCheckout.PureCoinPayableCent = pureCoin.PayableCent
if err := tx.Save(linkedCheckout).Error; err != nil {
return err
}
}
if err := rentergrowth.AwardOrderCompleted(tx, &order, pureCoin.AmountCent, rentergrowth.SourceOrderCompleted); err != nil {
return err
}
}
@@ -146,6 +163,8 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
"renter_refund_amount_cent": settlement.RenterRefundAmountCent,
"owner_income_amount_cent": settlement.OwnerIncomeAmountCent,
"deposit_deduct_amount_cent": settlement.DepositDeductAmountCent,
"actual_coin_consumed_m": order.ActualCoinConsumedM,
"actual_pure_coin_cent": order.ActualPureCoinAmountCent,
"before_order_status": beforeOrderStatus,
"after_order_status": order.Status,
"before_handoff_status": beforeHandoffStatus,
@@ -200,19 +219,22 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r
return &dto, nil
}
func arbitrationActualRentCent(order model.RentalOrder, settlement arbitrationSettlement) int64 {
rentRefundCent := settlement.RenterRefundAmountCent - settlement.RenterDepositRefundCent
if rentRefundCent < 0 {
rentRefundCent = 0
func arbitrationCoinConsumedM(tx *gorm.DB, row model.Dispute, req ArbitrateRequest) (float64, *model.OrderCheckout, error) {
var checkout *model.OrderCheckout
if row.CheckoutID != nil && *row.CheckoutID > 0 {
var saved model.OrderCheckout
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&saved, *row.CheckoutID).Error; err != nil {
return 0, nil, err
}
checkout = &saved
}
if rentRefundCent > order.RentAmountCent {
rentRefundCent = order.RentAmountCent
if req.ActualCoinConsumedM != nil {
return *req.ActualCoinConsumedM, checkout, nil
}
actualRentCent := order.RentAmountCent - rentRefundCent
if actualRentCent < 0 {
return 0
if checkout != nil {
return checkout.CoinConsumedM, checkout, nil
}
return actualRentCent
return 0, nil, ErrInvalidDispute
}
func isPlatformManagedOrder(order model.RentalOrder) bool {
+5 -3
View File
@@ -33,6 +33,7 @@ type DisputeDTO struct {
PreviousHandoffStatus string `json:"previous_handoff_status"`
PreviousSettlementStatus string `json:"previous_settlement_status"`
CheckoutID *uint64 `json:"checkout_id"`
CheckoutCoinConsumedM *float64 `json:"checkout_coin_consumed_m,omitempty"`
PreviousCheckoutStatus string `json:"previous_checkout_status"`
ArbitrationResult string `json:"arbitration_result"`
ArbitrationRemark string `json:"arbitration_remark"`
@@ -56,9 +57,10 @@ type AdminCreateRequest struct {
}
type ArbitrateRequest struct {
Result string `json:"result" binding:"required"`
Remark string `json:"remark" binding:"required"`
AmountCent int64 `json:"amount_cent"`
Result string `json:"result" binding:"required"`
Remark string `json:"remark" binding:"required"`
AmountCent int64 `json:"amount_cent"`
ActualCoinConsumedM *float64 `json:"actual_coin_consumed_m"`
}
type AdminListQuery struct {
+12 -10
View File
@@ -10,16 +10,17 @@ import (
type disputeRow struct {
model.Dispute
OrderNo string
OrderStatus string
HandoffStatus string
SettlementStatus string
ListingNo string
Title string
OwnerID uint64
RenterID uint64
OwnerPhone string
RenterPhone string
OrderNo string
OrderStatus string
HandoffStatus string
SettlementStatus string
ListingNo string
Title string
OwnerID uint64
RenterID uint64
OwnerPhone string
RenterPhone string
CheckoutCoinConsumedM *float64
}
func (row disputeRow) toDTO() DisputeDTO {
@@ -48,6 +49,7 @@ func (row disputeRow) toDTO() DisputeDTO {
PreviousHandoffStatus: row.PreviousHandoffStatus,
PreviousSettlementStatus: row.PreviousSettlementStatus,
CheckoutID: row.CheckoutID,
CheckoutCoinConsumedM: row.CheckoutCoinConsumedM,
PreviousCheckoutStatus: row.PreviousCheckoutStatus,
ArbitrationResult: row.ArbitrationResult,
ArbitrationRemark: row.ArbitrationRemark,
+3 -2
View File
@@ -92,7 +92,7 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
return r.adminFilterQuery(ctx).
Select(`d.*, o.order_no, o.status AS order_status, o.handoff_status, o.settlement_status,
o.owner_id, o.renter_id, owner.phone AS owner_phone, renter.phone AS renter_phone,
l.listing_no, a.title`)
l.listing_no, a.title, c.coin_consumed_m AS checkout_coin_consumed_m`)
}
func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB {
@@ -101,7 +101,8 @@ func (r *Repository) adminFilterQuery(ctx context.Context) *gorm.DB {
Joins("JOIN rental_listings AS l ON l.id = o.listing_id").
Joins("JOIN game_accounts AS a ON a.id = o.account_id").
Joins("JOIN users AS owner ON owner.id = o.owner_id").
Joins("JOIN users AS renter ON renter.id = o.renter_id")
Joins("JOIN users AS renter ON renter.id = o.renter_id").
Joins("LEFT JOIN order_checkouts AS c ON c.id = d.checkout_id")
}
func applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB {
@@ -1,6 +1,8 @@
package dispute
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -8,9 +10,36 @@ import (
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm"
)
func disputePureCoinOrder() model.RentalOrder {
return model.RentalOrder{
Status: "renting",
HandoffStatus: "received",
SettlementStatus: "unsettled",
RentAmountCent: 38037,
OwnerRentAmountCent: 35300,
DepositAmountCent: 15000,
RentOriginalAmountCent: 38300,
RentDiscountAmountCent: 263,
PureCoinOriginalAmountCent: 26300,
ExtraItemOriginalAmountCent: 12000,
RenterDiscountBps: 9900,
AccountSnapshot: datatypes.JSON([]byte(`{
"haf_coin_amount": 100000000,
"asset_summary": {
"price_breakdown": {
"buyer_coin_base_price": 263,
"seller_coin_base_price": 233,
"consumable_price": 120
}
}
}`)),
}
}
func setupDisputeTestDB(t *testing.T) *gorm.DB {
t.Helper()
db := database.NewTestDB()
@@ -261,10 +290,12 @@ func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
if adminNotificationCount == 0 {
t.Fatal("platform managed dispute should notify managed admin")
}
actualCoinConsumedM := 0.0
if _, err := repo.Arbitrate(t.Context(), adminID, created.ID, ArbitrateRequest{
Result: "release_deposit",
Remark: "确认租金给卖家,线下结算",
Result: "release_deposit",
Remark: "确认租金给卖家,线下结算",
ActualCoinConsumedM: &actualCoinConsumedM,
}, AuditMeta{}); err != nil {
t.Fatalf("仲裁失败: %v", err)
}
@@ -291,6 +322,90 @@ func TestPlatformManagedArbitrationUsesOfflineSettlement(t *testing.T) {
}
}
func TestArbitrationCompletedOrderRequiresActualCoinConsumedM(t *testing.T) {
db := setupDisputeTestDB(t)
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
return "refunded", nil
})})
_, renter, order := createDisputeOrderFixture(t, db, disputePureCoinOrder())
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
Type: "haf_coin_dispute",
Description: "核对最终哈夫币消耗",
})
if err != nil {
t.Fatalf("创建申诉失败: %v", err)
}
_, err = repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
Result: "release_deposit",
Remark: "确认完成订单",
}, AuditMeta{})
if !errors.Is(err, ErrInvalidDispute) {
t.Fatalf("缺少实际消耗 M 的仲裁错误 = %v, want ErrInvalidDispute", err)
}
actualCoinConsumedM := 50.0
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
Result: "release_deposit",
Remark: "确认实际消耗 50M",
ActualCoinConsumedM: &actualCoinConsumedM,
}, AuditMeta{}); err != nil {
t.Fatalf("带实际消耗 M 仲裁失败: %v", err)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if saved.ActualCoinConsumedM != 50 || saved.ActualPureCoinAmountCent != 13150 || saved.GrowthPointsAwarded != 131 {
t.Fatalf("最终 M/纯币/积分 = %.2f/%d/%d, want 50/13150/131", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.GrowthPointsAwarded)
}
}
func TestCheckoutDisputeArbitrationReusesCheckoutCoinConsumedM(t *testing.T) {
db := setupDisputeTestDB(t)
repo := NewRepository(db, Dependencies{RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) {
return "refunded", nil
})})
inputOrder := disputePureCoinOrder()
inputOrder.Status = "pending_checkout_confirm"
inputOrder.HandoffStatus = "pending_owner_checkout"
inputOrder.SettlementStatus = "pending"
_, renter, order := createDisputeOrderFixture(t, db, inputOrder)
checkout := model.OrderCheckout{
OrderID: order.ID,
InitiatedBy: renter.ID,
Status: "submitted",
CoinConsumedM: 25,
}
if err := db.Create(&checkout).Error; err != nil {
t.Fatalf("创建结账记录失败: %v", err)
}
created, err := repo.Create(t.Context(), renter.ID, order.ID, CreateRequest{
Type: "checkout_amount",
Description: "结账金额有争议",
})
if err != nil {
t.Fatalf("创建结账争议失败: %v", err)
}
if created.CheckoutID == nil || *created.CheckoutID != checkout.ID || created.CheckoutCoinConsumedM == nil || *created.CheckoutCoinConsumedM != 25 {
t.Fatalf("关联结账/M = %v/%v, want %d/25", created.CheckoutID, created.CheckoutCoinConsumedM, checkout.ID)
}
if _, err := repo.Arbitrate(t.Context(), 77, created.ID, ArbitrateRequest{
Result: "release_deposit",
Remark: "沿用双方结账消耗",
}, AuditMeta{}); err != nil {
t.Fatalf("仲裁复用结账 M 失败: %v", err)
}
var saved model.RentalOrder
if err := db.First(&saved, order.ID).Error; err != nil {
t.Fatalf("读取订单失败: %v", err)
}
if saved.ActualCoinConsumedM != 25 || saved.ActualPureCoinAmountCent != 6575 || saved.GrowthPointsAwarded != 65 {
t.Fatalf("最终 M/纯币/积分 = %.2f/%d/%d, want 25/6575/65", saved.ActualCoinConsumedM, saved.ActualPureCoinAmountCent, saved.GrowthPointsAwarded)
}
}
func TestBuildArbitrationSettlementSkipsFrozenReleaseWhenNoFrozenBalance(t *testing.T) {
order := model.RentalOrder{
ID: 11,
@@ -90,5 +90,8 @@ func (s *Service) Arbitrate(ctx context.Context, adminID uint64, id uint64, req
if id == 0 || req.Result == "" || req.Remark == "" {
return nil, ErrInvalidDispute
}
if req.ActualCoinConsumedM != nil && (*req.ActualCoinConsumedM < 0 || *req.ActualCoinConsumedM > 1_000_000_000) {
return nil, ErrInvalidDispute
}
return s.repo.Arbitrate(ctx, adminID, id, req, meta)
}
@@ -255,6 +255,9 @@ func applyCounterCheckoutUpdate(checkout *model.OrderCheckout, next model.OrderC
checkout.OwnerRentAmountCent = next.OwnerRentAmountCent
checkout.PlatformFeeCent = next.PlatformFeeCent
checkout.DepositAmountCent = next.DepositAmountCent
checkout.PureCoinAmountCent = next.PureCoinAmountCent
checkout.PureCoinDiscountCent = next.PureCoinDiscountCent
checkout.PureCoinPayableCent = next.PureCoinPayableCent
checkout.ConsumableAmountCent = next.ConsumableAmountCent
checkout.CoinConsumedM = next.CoinConsumedM
checkout.OtherAmountCent = next.OtherAmountCent
@@ -40,10 +40,13 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
return nil, err
}
applyCheckoutSettlement(checkout, settlement)
order.ActualCoinConsumedM = settlement.CoinConsumedM
order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent
order.ActualPureCoinDiscountCent = settlement.PureCoinDiscountCent
if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil {
return nil, err
}
if err := rentergrowth.AwardOrderCompleted(tx, order, settlement.ActualRentAmountCent, rentergrowth.SourceOrderCompleted); err != nil {
if err := rentergrowth.AwardOrderCompleted(tx, order, settlement.PureCoinAmountCent, rentergrowth.SourceOrderCompleted); err != nil {
return nil, err
}
if err := saveFinalizedCheckout(tx, order, checkout, listing, account); err != nil {
@@ -110,6 +113,9 @@ func applyCheckoutSettlement(checkout *model.OrderCheckout, settlement checkoutS
checkout.RentAmountCent = settlement.ActualRentAmountCent
checkout.OwnerRentAmountCent = settlement.OwnerRentIncomeCent
checkout.PlatformFeeCent = settlement.PlatformFeeCent
checkout.PureCoinAmountCent = settlement.PureCoinAmountCent
checkout.PureCoinDiscountCent = settlement.PureCoinDiscountCent
checkout.PureCoinPayableCent = settlement.PureCoinPayableCent
checkout.RenterRefundAmountCent = settlement.RenterRefundCent
checkout.OwnerIncomeAmountCent = settlement.OwnerIncomeCent
checkout.ShortfallCent = settlement.ShortfallCent
+10
View File
@@ -37,9 +37,16 @@ type OrderDTO struct {
PlatformFeeCent *int64 `json:"platform_fee_cent,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"`
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"`
@@ -191,6 +198,9 @@ type CheckoutDTO struct {
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
PureCoinAmountCent int64 `json:"pure_coin_amount_cent"`
PureCoinDiscountCent int64 `json:"pure_coin_discount_cent"`
PureCoinPayableCent int64 `json:"pure_coin_payable_cent"`
ConsumableAmountCent int64 `json:"consumable_amount_cent"`
CoinConsumedM float64 `json:"coin_consumed_m"`
OtherAmountCent int64 `json:"other_amount_cent"`
+31 -29
View File
@@ -48,7 +48,7 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
if err != nil {
return err
}
rentDiscountAmountCent := rentergrowth.CalculateDiscountCent(rentOriginalAmountCent, pricing.PlatformFeeCent, growthSnapshot.DiscountBps)
rentDiscountAmountCent := rentergrowth.CalculateDiscountCent(pricing.PureCoinAmountCent, pricing.PureCoinPlatformFeeCent, growthSnapshot.DiscountBps)
if rentDiscountAmountCent > 0 {
pricing.PlatformFeeCent -= rentDiscountAmountCent
pricing.RentAmountCent -= rentDiscountAmountCent
@@ -59,34 +59,36 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
return err
}
order := model.RentalOrder{
OrderNo: orderNo,
ListingID: listing.ID,
AccountID: listing.AccountID,
OwnerID: listing.OwnerID,
RenterID: renterID,
EstimatedDurationHours: rentHours,
RentAmountCent: pricing.RentAmountCent,
OwnerRentAmountCent: pricing.OwnerRentAmountCent,
DepositAmountCent: depositWaiver.PaidCent,
DepositOriginalAmountCent: depositOriginalAmountCent,
DepositWaivedAmountCent: depositWaiver.WaivedCent,
DepositFreeLevelQuotaCent: growthSnapshot.LevelDepositFreeQuotaCent,
DepositFreeManualQuotaCent: growthSnapshot.ManualDepositFreeQuotaCent,
DepositFreeUsedBeforeCent: depositWaiver.UsedBeforeCent,
PlatformFeeCent: pricing.PlatformFeeCent,
RentOriginalAmountCent: rentOriginalAmountCent,
RentDiscountAmountCent: rentDiscountAmountCent,
RenterGrowthLevel: growthSnapshot.LevelCode,
RenterGrowthLevelName: growthSnapshot.LevelName,
RenterDiscountBps: growthSnapshot.DiscountBps,
AccountSnapshot: snapshot,
Status: orderStatusPendingPayment,
HandoffStatus: handoffStatusNone,
HandoffMode: listingHandoffMode(listing),
SettlementMode: listingSettlementMode(listing),
ManagedAdminID: listing.ManagedAdminID,
SettlementStatus: settlementStatusUnsettled,
OfflineSettlementStatus: offlineSettlementStatusNone,
OrderNo: orderNo,
ListingID: listing.ID,
AccountID: listing.AccountID,
OwnerID: listing.OwnerID,
RenterID: renterID,
EstimatedDurationHours: rentHours,
RentAmountCent: pricing.RentAmountCent,
OwnerRentAmountCent: pricing.OwnerRentAmountCent,
DepositAmountCent: depositWaiver.PaidCent,
DepositOriginalAmountCent: depositOriginalAmountCent,
DepositWaivedAmountCent: depositWaiver.WaivedCent,
DepositFreeLevelQuotaCent: growthSnapshot.LevelDepositFreeQuotaCent,
DepositFreeManualQuotaCent: growthSnapshot.ManualDepositFreeQuotaCent,
DepositFreeUsedBeforeCent: depositWaiver.UsedBeforeCent,
PlatformFeeCent: pricing.PlatformFeeCent,
RentOriginalAmountCent: rentOriginalAmountCent,
RentDiscountAmountCent: rentDiscountAmountCent,
PureCoinOriginalAmountCent: pricing.PureCoinAmountCent,
ExtraItemOriginalAmountCent: pricing.ExtraItemAmountCent,
RenterGrowthLevel: growthSnapshot.LevelCode,
RenterGrowthLevelName: growthSnapshot.LevelName,
RenterDiscountBps: growthSnapshot.DiscountBps,
AccountSnapshot: snapshot,
Status: orderStatusPendingPayment,
HandoffStatus: handoffStatusNone,
HandoffMode: listingHandoffMode(listing),
SettlementMode: listingSettlementMode(listing),
ManagedAdminID: listing.ManagedAdminID,
SettlementStatus: settlementStatusUnsettled,
OfflineSettlementStatus: offlineSettlementStatusNone,
}
if err := tx.Create(&order).Error; err != nil {
return err
@@ -87,9 +87,16 @@ func (row orderRow) toAdminDTO() OrderDTO {
PlatformFeeCent: &platformFeeCent,
RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder),
RentDiscountAmountCent: row.RentDiscountAmountCent,
PureCoinOriginalAmountCent: orderPureCoinOriginalAmountCent(row.RentalOrder),
ExtraItemOriginalAmountCent: orderExtraItemOriginalAmountCent(row.RentalOrder),
ActualCoinConsumedM: row.ActualCoinConsumedM,
ActualPureCoinAmountCent: row.ActualPureCoinAmountCent,
ActualPureCoinDiscountCent: row.ActualPureCoinDiscountCent,
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,
@@ -225,6 +232,9 @@ func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO {
OwnerRentAmountCent: &ownerRentAmountCent,
PlatformFeeCent: &platformFeeCent,
DepositAmountCent: checkout.DepositAmountCent,
PureCoinAmountCent: checkout.PureCoinAmountCent,
PureCoinDiscountCent: checkout.PureCoinDiscountCent,
PureCoinPayableCent: checkout.PureCoinPayableCent,
ConsumableAmountCent: checkout.ConsumableAmountCent,
CoinConsumedM: checkout.CoinConsumedM,
OtherAmountCent: checkout.OtherAmountCent,
@@ -315,11 +325,18 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64)
dto.OwnerRentAmountCent = &ownerAmountCent
dto.RentOriginalAmountCent = 0
dto.RentDiscountAmountCent = 0
dto.PureCoinOriginalAmountCent = 0
dto.ExtraItemOriginalAmountCent = 0
dto.ActualCoinConsumedM = 0
dto.ActualPureCoinAmountCent = 0
dto.ActualPureCoinDiscountCent = 0
dto.RenterGrowthLevel = ""
dto.RenterGrowthLevelName = ""
dto.RenterDiscountBps = 0
dto.GrowthPointsAwarded = 0
dto.GrowthPointsAwardedAt = nil
dto.GrowthPointsBasisCent = 0
dto.GrowthPointsPerYuan = 0
sanitizeOrderSnapshot(&dto.AccountSnapshot, "owner")
case userID == order.RenterID:
rentAmountCent := order.RentAmountCent
@@ -340,11 +357,18 @@ func applyOrderPriceView(dto *OrderDTO, order model.RentalOrder, userID uint64)
dto.OwnerRentAmountCent = nil
dto.RentOriginalAmountCent = 0
dto.RentDiscountAmountCent = 0
dto.PureCoinOriginalAmountCent = 0
dto.ExtraItemOriginalAmountCent = 0
dto.ActualCoinConsumedM = 0
dto.ActualPureCoinAmountCent = 0
dto.ActualPureCoinDiscountCent = 0
dto.RenterGrowthLevel = ""
dto.RenterGrowthLevelName = ""
dto.RenterDiscountBps = 0
dto.GrowthPointsAwarded = 0
dto.GrowthPointsAwardedAt = nil
dto.GrowthPointsBasisCent = 0
dto.GrowthPointsPerYuan = 0
sanitizeOrderSnapshot(&dto.AccountSnapshot, "")
}
}
@@ -379,6 +403,9 @@ func applyCheckoutPriceView(dto *CheckoutDTO, order model.RentalOrder, userID ui
dto.RentAmountCent = nil
dto.OwnerRentAmountCent = &ownerAmountCent
dto.OwnerIncomeAmountCent = &ownerIncomeAmountCent
dto.PureCoinAmountCent = 0
dto.PureCoinDiscountCent = 0
dto.PureCoinPayableCent = 0
case userID == order.RenterID:
dto.PriceRole = "renter"
dto.DisplayAmountCent = rentAmountCent
@@ -390,6 +417,9 @@ func applyCheckoutPriceView(dto *CheckoutDTO, order model.RentalOrder, userID ui
dto.DisplayAmountCent = 0
dto.RentAmountCent = nil
dto.OwnerRentAmountCent = nil
dto.PureCoinAmountCent = 0
dto.PureCoinDiscountCent = 0
dto.PureCoinPayableCent = 0
}
}
+104 -16
View File
@@ -7,15 +7,21 @@ import (
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/pkg/money"
"gorm.io/datatypes"
)
type orderPricing struct {
RentAmountCent int64
OwnerRentAmountCent int64
PlatformFeeCent int64
RentAmountCent int64
OwnerRentAmountCent int64
PlatformFeeCent int64
PureCoinAmountCent int64
OwnerPureCoinAmountCent int64
ExtraItemAmountCent int64
OwnerExtraItemAmountCent int64
PureCoinPlatformFeeCent int64
}
type checkoutSettlement struct {
@@ -27,10 +33,34 @@ type checkoutSettlement struct {
RenterRefundCent int64
PlatformFeeCent int64
ActualRentAmountCent int64
PureCoinAmountCent int64
PureCoinDiscountCent int64
PureCoinPayableCent int64
ExtraItemAmountCent int64
CoinConsumedM float64
OvershootAmountCent int64
ShortfallCent int64
}
// ActualPureCoinAmounts 是跨正常结账与仲裁共用的最终纯币计价结果。
type ActualPureCoinAmounts struct {
AmountCent int64
DiscountCent int64
PayableCent int64
ConsumedM float64
}
// CalculateActualPureCoinAmounts 按订单价格快照和实际消耗 M 计算纯币原价及等级优惠。
func CalculateActualPureCoinAmounts(order model.RentalOrder, coinConsumedM float64) ActualPureCoinAmounts {
settlement := calculateCheckoutSettlement(order, 0, coinConsumedM, 0)
return ActualPureCoinAmounts{
AmountCent: settlement.PureCoinAmountCent,
DiscountCent: settlement.PureCoinDiscountCent,
PayableCent: settlement.PureCoinPayableCent,
ConsumedM: settlement.CoinConsumedM,
}
}
func orderDurationHours(order model.RentalOrder) int {
if order.EstimatedDurationHours > 0 {
return order.EstimatedDurationHours
@@ -85,10 +115,34 @@ func buildOrderPricing(listing model.RentalListing, account model.GameAccount) o
if platformFeeCent < 0 {
platformFeeCent = 0
}
pureCoinAmountCent := int64(math.Round(readSnapshotPrice(account.AssetSummary, "buyer_coin_base_price") * 100))
extraItemSnapshotCent := int64(math.Round(readSnapshotPrice(account.AssetSummary, "consumable_price") * 100))
if pureCoinAmountCent <= 0 {
if extraItemSnapshotCent > 0 && extraItemSnapshotCent <= rentAmountCent {
pureCoinAmountCent = rentAmountCent - extraItemSnapshotCent
} else {
pureCoinAmountCent = rentAmountCent
}
}
if pureCoinAmountCent < 0 || pureCoinAmountCent > rentAmountCent {
pureCoinAmountCent = rentAmountCent
}
extraItemAmountCent := maxCent(rentAmountCent-pureCoinAmountCent, 0)
ownerPureCoinAmountCent := int64(math.Round(readSnapshotPrice(account.AssetSummary, "seller_coin_base_price") * 100))
if ownerPureCoinAmountCent <= 0 || ownerPureCoinAmountCent > ownerRentAmountCent {
ownerPureCoinAmountCent = maxCent(ownerRentAmountCent-minCent(extraItemAmountCent, ownerRentAmountCent), 0)
}
ownerExtraItemAmountCent := maxCent(ownerRentAmountCent-ownerPureCoinAmountCent, 0)
pureCoinPlatformFeeCent := maxCent(pureCoinAmountCent-ownerPureCoinAmountCent, 0)
return orderPricing{
RentAmountCent: rentAmountCent,
OwnerRentAmountCent: ownerRentAmountCent,
PlatformFeeCent: platformFeeCent,
RentAmountCent: rentAmountCent,
OwnerRentAmountCent: ownerRentAmountCent,
PlatformFeeCent: platformFeeCent,
PureCoinAmountCent: pureCoinAmountCent,
OwnerPureCoinAmountCent: ownerPureCoinAmountCent,
ExtraItemAmountCent: extraItemAmountCent,
OwnerExtraItemAmountCent: ownerExtraItemAmountCent,
PureCoinPlatformFeeCent: pureCoinPlatformFeeCent,
}
}
@@ -162,6 +216,9 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c
OwnerRentAmountCent: settlement.OwnerRentIncomeCent,
PlatformFeeCent: settlement.PlatformFeeCent,
DepositAmountCent: order.DepositAmountCent,
PureCoinAmountCent: settlement.PureCoinAmountCent,
PureCoinDiscountCent: settlement.PureCoinDiscountCent,
PureCoinPayableCent: settlement.PureCoinPayableCent,
ConsumableAmountCent: consumableAmountCent,
CoinConsumedM: roundQuantity(coinConsumedM),
OtherAmountCent: otherAmountCent,
@@ -185,18 +242,13 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
orderOwnerRentAmountCent := order.OwnerRentAmountCent
orderDepositAmountCent := order.DepositAmountCent
buyerCoinBasePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "buyer_coin_base_price") * 100))
if buyerCoinBasePriceCent <= 0 || buyerCoinBasePriceCent > orderRentAmountCent {
buyerCoinBasePriceCent = orderRentAmountCent
}
buyerCoinBasePriceCent := orderPureCoinOriginalAmountCent(order)
sellerCoinBasePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "seller_coin_base_price") * 100))
if sellerCoinBasePriceCent <= 0 || sellerCoinBasePriceCent > orderOwnerRentAmountCent {
sellerCoinBasePriceCent = orderOwnerRentAmountCent
}
prepaidConsumablePriceCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "consumable_price") * 100))
if prepaidConsumablePriceCent <= 0 || prepaidConsumablePriceCent > orderRentAmountCent-buyerCoinBasePriceCent {
prepaidConsumablePriceCent = maxCent(orderRentAmountCent-buyerCoinBasePriceCent, 0)
ownerExtraItemCent := minCent(orderExtraItemOriginalAmountCent(order), orderOwnerRentAmountCent)
sellerCoinBasePriceCent = maxCent(orderOwnerRentAmountCent-ownerExtraItemCent, 0)
}
prepaidConsumablePriceCent := orderExtraItemOriginalAmountCent(order)
prepaidOwnerConsumablePriceCent := maxCent(orderOwnerRentAmountCent-sellerCoinBasePriceCent, 0)
consumedM := roundQuantity(coinConsumedM)
@@ -243,7 +295,10 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
usedOwnerConsumablePriceCent = minCent(usedOwnerConsumablePriceCent, prepaidOwnerConsumablePriceCent)
}
actualBuyerRentCent := usedBuyerCoinPriceCent + usedBuyerConsumablePriceCent
pureCoinPlatformFeeCent := maxCent(usedBuyerCoinPriceCent-usedOwnerCoinPriceCent, 0)
pureCoinDiscountCent := rentergrowth.CalculateDiscountCent(usedBuyerCoinPriceCent, pureCoinPlatformFeeCent, effectiveRenterDiscountBps(order))
pureCoinPayableCent := maxCent(usedBuyerCoinPriceCent-pureCoinDiscountCent, 0)
actualBuyerRentCent := pureCoinPayableCent + usedBuyerConsumablePriceCent
actualOwnerRentCent := usedOwnerCoinPriceCent + usedOwnerConsumablePriceCent
if actualOwnerRentCent > actualBuyerRentCent {
actualOwnerRentCent = actualBuyerRentCent
@@ -278,11 +333,44 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
RenterRefundCent: rentRefundCent + depositRefundCent,
PlatformFeeCent: platformFeeCent,
ActualRentAmountCent: actualRentAmountCent,
PureCoinAmountCent: usedBuyerCoinPriceCent,
PureCoinDiscountCent: pureCoinDiscountCent,
PureCoinPayableCent: pureCoinPayableCent,
ExtraItemAmountCent: usedBuyerConsumablePriceCent,
CoinConsumedM: consumedM,
OvershootAmountCent: overshootCent,
ShortfallCent: shortfallCent,
}
}
func orderPureCoinOriginalAmountCent(order model.RentalOrder) int64 {
originalRentCent := effectiveRentOriginalAmountCent(order)
if order.PureCoinOriginalAmountCent > 0 {
return minCent(order.PureCoinOriginalAmountCent, originalRentCent)
}
if order.ExtraItemOriginalAmountCent > 0 {
return maxCent(originalRentCent-minCent(order.ExtraItemOriginalAmountCent, originalRentCent), 0)
}
pureCoinCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "buyer_coin_base_price") * 100))
if pureCoinCent > 0 && pureCoinCent <= originalRentCent {
return pureCoinCent
}
extraItemCent := int64(math.Round(readOrderSnapshotPrice(order.AccountSnapshot, "consumable_price") * 100))
if extraItemCent > 0 && extraItemCent <= originalRentCent {
return originalRentCent - extraItemCent
}
return originalRentCent
}
func orderExtraItemOriginalAmountCent(order model.RentalOrder) int64 {
originalRentCent := effectiveRentOriginalAmountCent(order)
if order.ExtraItemOriginalAmountCent > 0 {
return minCent(order.ExtraItemOriginalAmountCent, originalRentCent)
}
pureCoinCent := orderPureCoinOriginalAmountCent(order)
return maxCent(originalRentCent-pureCoinCent, 0)
}
func readOrderSnapshotCoinM(raw datatypes.JSON) float64 {
if len(raw) == 0 {
return 0
@@ -162,7 +162,15 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
ServerRegion: "国服",
LoginPlatform: "steam",
Title: "测试账号",
AssetSummary: datatypes.JSON([]byte(`{"price_breakdown":{"seller_total_price":90,"platform_markup_amount":10}}`)),
AssetSummary: datatypes.JSON([]byte(`{
"price_breakdown": {
"buyer_coin_base_price": 263,
"seller_coin_base_price": 233,
"consumable_price": 120,
"seller_total_price": 353,
"platform_markup_amount": 30
}
}`)),
}
if err := db.Create(&account).Error; err != nil {
t.Fatalf("create account failed: %v", err)
@@ -170,7 +178,7 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
listing := model.RentalListing{
AccountID: account.ID,
OwnerID: owner.ID,
PriceCent: 10000,
PriceCent: 38300,
DepositAmountCent: 100000,
Status: listingStatusPublished,
ReviewStatus: listingReviewStatusApproved,
@@ -183,11 +191,14 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if dto.RentAmountCent == nil || *dto.RentAmountCent != 9900 {
t.Fatalf("rent amount = %v, want 9900", dto.RentAmountCent)
if dto.RentAmountCent == nil || *dto.RentAmountCent != 38037 {
t.Fatalf("rent amount = %v, want 38037", dto.RentAmountCent)
}
if dto.RentOriginalAmountCent != 10000 || dto.RentDiscountAmountCent != 100 {
t.Fatalf("discount snapshot = original %d discount %d, want 10000/100", dto.RentOriginalAmountCent, dto.RentDiscountAmountCent)
if dto.RentOriginalAmountCent != 38300 || dto.RentDiscountAmountCent != 263 {
t.Fatalf("discount snapshot = original %d discount %d, want 38300/263", dto.RentOriginalAmountCent, dto.RentDiscountAmountCent)
}
if dto.PureCoinOriginalAmountCent != 26300 || dto.ExtraItemOriginalAmountCent != 12000 {
t.Fatalf("pure coin/extra snapshot = %d/%d, want 26300/12000", dto.PureCoinOriginalAmountCent, dto.ExtraItemOriginalAmountCent)
}
if dto.RenterGrowthLevel != "platinum" || dto.RenterGrowthLevelName != "铂金" || dto.RenterDiscountBps != 9900 {
t.Fatalf("growth snapshot = %s/%s/%d, want platinum/铂金/9900", dto.RenterGrowthLevel, dto.RenterGrowthLevelName, dto.RenterDiscountBps)
@@ -196,8 +207,8 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) {
if err := db.First(&saved, dto.ID).Error; err != nil {
t.Fatalf("find saved order failed: %v", err)
}
if saved.OwnerRentAmountCent != 9000 || saved.PlatformFeeCent != 900 {
t.Fatalf("pricing = owner %d platform %d, want 9000/900", saved.OwnerRentAmountCent, saved.PlatformFeeCent)
if saved.OwnerRentAmountCent != 35300 || saved.PlatformFeeCent != 2737 {
t.Fatalf("pricing = owner %d platform %d, want 35300/2737", saved.OwnerRentAmountCent, saved.PlatformFeeCent)
}
if saved.DepositAmountCent != 30000 || saved.DepositWaivedAmountCent != 70000 {
t.Fatalf("deposit = paid %d waived %d, want 30000/70000", saved.DepositAmountCent, saved.DepositWaivedAmountCent)
@@ -5,11 +5,116 @@ import (
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/internal/timeutil"
"gorm.io/datatypes"
)
func pureCoinDiscountOrder() model.RentalOrder {
return model.RentalOrder{
RentAmountCent: 38037,
OwnerRentAmountCent: 35300,
DepositAmountCent: 15000,
RentOriginalAmountCent: 38300,
RentDiscountAmountCent: 263,
PureCoinOriginalAmountCent: 26300,
ExtraItemOriginalAmountCent: 12000,
RenterDiscountBps: 9900,
AccountSnapshot: datatypes.JSON([]byte(`{
"haf_coin_amount": 100000000,
"asset_summary": {
"price_breakdown": {
"buyer_coin_base_price": 263,
"seller_coin_base_price": 233,
"consumable_price": 120
}
}
}`)),
}
}
func TestCalculateCheckoutSettlementUsesOnlyActualPureCoinForDiscount(t *testing.T) {
tests := []struct {
name string
consumableAmountCent int64
coinConsumedM float64
wantPureCoinCent int64
wantDiscountCent int64
wantActualRentCent int64
wantOvershootCent int64
}{
{
name: "全量纯币和额外物品",
consumableAmountCent: 12000,
coinConsumedM: 100,
wantPureCoinCent: 26300,
wantDiscountCent: 263,
wantActualRentCent: 38037,
wantOvershootCent: 0,
},
{
name: "部分纯币和全量额外物品",
consumableAmountCent: 12000,
coinConsumedM: 50,
wantPureCoinCent: 13150,
wantDiscountCent: 132,
wantActualRentCent: 25018,
wantOvershootCent: 0,
},
{
name: "只使用额外物品",
consumableAmountCent: 12000,
coinConsumedM: 0,
wantPureCoinCent: 0,
wantDiscountCent: 0,
wantActualRentCent: 12000,
wantOvershootCent: 0,
},
{
name: "纯币打超仍参与优惠",
consumableAmountCent: 12000,
coinConsumedM: 110,
wantPureCoinCent: 28930,
wantDiscountCent: 290,
wantActualRentCent: 40640,
wantOvershootCent: 2603,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
settlement := calculateCheckoutSettlement(pureCoinDiscountOrder(), test.consumableAmountCent, test.coinConsumedM, 0)
if settlement.PureCoinAmountCent != test.wantPureCoinCent || settlement.PureCoinDiscountCent != test.wantDiscountCent {
t.Fatalf("纯币原价/优惠 = %d/%d, want %d/%d", settlement.PureCoinAmountCent, settlement.PureCoinDiscountCent, test.wantPureCoinCent, test.wantDiscountCent)
}
if settlement.ActualRentAmountCent != test.wantActualRentCent || settlement.OvershootAmountCent != test.wantOvershootCent {
t.Fatalf("实际租金/打超 = %d/%d, want %d/%d", settlement.ActualRentAmountCent, settlement.OvershootAmountCent, test.wantActualRentCent, test.wantOvershootCent)
}
})
}
}
func TestBuildOrderPricingDoesNotDiscountAllExtraItemOrder(t *testing.T) {
pricing := buildOrderPricing(
model.RentalListing{PriceCent: 12000},
model.GameAccount{AssetSummary: datatypes.JSON([]byte(`{
"price_breakdown": {
"buyer_coin_base_price": 0,
"seller_coin_base_price": 0,
"consumable_price": 120,
"seller_total_price": 120
}
}`))},
)
if pricing.PureCoinAmountCent != 0 || pricing.ExtraItemAmountCent != 12000 || pricing.PureCoinPlatformFeeCent != 0 {
t.Fatalf("全额外物品订单纯币/额外物品/纯币差价 = %d/%d/%d, want 0/12000/0", pricing.PureCoinAmountCent, pricing.ExtraItemAmountCent, pricing.PureCoinPlatformFeeCent)
}
if discount := rentergrowth.CalculateDiscountCent(pricing.PureCoinAmountCent, pricing.PureCoinPlatformFeeCent, 9900); discount != 0 {
t.Fatalf("全额外物品订单优惠 = %d, want 0", discount)
}
}
func TestCalculateCheckoutSettlementRefundsUnusedRent(t *testing.T) {
order := model.RentalOrder{
RentAmountCent: 38300,
+21 -10
View File
@@ -6,6 +6,7 @@ import (
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/rentergrowth"
"go.uber.org/zap"
"gorm.io/datatypes"
@@ -273,7 +274,7 @@ func (r *Repository) prepareRefundOrder(ctx context.Context, originalPayment mod
}
func (r *Repository) syncRefundPayment(ctx context.Context, payment *model.PaymentOrder, queryTerminal bool) (*model.PaymentOrder, error) {
if payment.Status == "refunded" && !queryTerminal {
if err := r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent); err != nil {
if err := r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType); err != nil {
return nil, err
}
return payment, nil
@@ -290,7 +291,7 @@ func (r *Repository) syncRefundPayment(ctx context.Context, payment *model.Payme
}
if runtimeConfig.isMockMode() {
if payment.Status == "refunded" {
if err := r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent); err != nil {
if err := r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType); err != nil {
return nil, err
}
}
@@ -371,7 +372,7 @@ func (r *Repository) applyRefundChannelStatus(ctx context.Context, payment *mode
}
switch status {
case "refunded":
return r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent)
return r.updateOrderRefundStatus(ctx, payment.OrderID, payment.AmountCent, payment.BizType)
case "failed":
return r.markOrderRefundFailed(ctx, payment.OrderID, payment.AmountCent)
default:
@@ -394,13 +395,23 @@ func refundQueryRequest(payment model.PaymentOrder, originalPayment model.Paymen
ProviderRefundID: payment.ProviderOrderID,
}
}
func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64, refundAmountCent int64) error {
now := time.Now()
return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{
"refund_status": "refunded",
"refund_amount_cent": refundAmountCent,
"refunded_at": now,
}).Error
func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.RentalOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err
}
now := time.Now()
order.RefundStatus = "refunded"
order.RefundAmountCent = refundAmountCent
order.RefundedAt = &now
if bizType == "admin_refund" && order.Status == "completed" && refundAmountCent >= order.RentAmountCent+order.DepositAmountCent {
if err := rentergrowth.RevokeOrderCompleted(tx, &order); err != nil {
return err
}
}
return tx.Save(&order).Error
})
}
func (r *Repository) markOrderRefunding(ctx context.Context, orderID uint64, refundAmountCent int64) error {
return r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{
@@ -9,6 +9,7 @@ import (
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
ordermodule "hfb_sys/backend/internal/modules/order"
"hfb_sys/backend/internal/modules/rentergrowth"
"gorm.io/gorm"
)
@@ -388,6 +389,55 @@ func TestRefundAmountMustNotExceedOriginal(t *testing.T) {
}
}
func TestSuccessfulAdminFullRefundRevokesCompletedOrderGrowthPoints(t *testing.T) {
db := setupPaymentTestDB(t)
repo := NewRepository(db, nil, nil)
renter := model.User{Phone: "13900001991", RenterGrowthLevel: "normal"}
if err := db.Create(&renter).Error; err != nil {
t.Fatalf("create renter failed: %v", err)
}
order := model.RentalOrder{
OrderNo: "ORD202607260991",
RenterID: renter.ID,
RentAmountCent: 1000,
Status: "completed",
GrowthPointsBasisCent: 1000,
GrowthPointsPerYuan: 1,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
}
if err := rentergrowth.AwardOrderCompleted(db, &order, 1000, rentergrowth.SourceOrderCompleted); err != nil {
t.Fatalf("award growth points failed: %v", err)
}
if err := db.Save(&order).Error; err != nil {
t.Fatalf("save awarded order failed: %v", err)
}
if err := repo.updateOrderRefundStatus(t.Context(), order.ID, 1000, "admin_refund"); err != nil {
t.Fatalf("update admin refund status failed: %v", err)
}
if err := repo.updateOrderRefundStatus(t.Context(), order.ID, 1000, "admin_refund"); err != nil {
t.Fatalf("repeat admin refund status failed: %v", err)
}
var savedUser model.User
if err := db.First(&savedUser, renter.ID).Error; err != nil {
t.Fatalf("find renter failed: %v", err)
}
if savedUser.RenterGrowthPoints != 0 || savedUser.RenterGrowthLevel != rentergrowth.DefaultLevelCode {
t.Fatalf("growth after refund = %d/%s, want 0/%s", savedUser.RenterGrowthPoints, savedUser.RenterGrowthLevel, rentergrowth.DefaultLevelCode)
}
var revokeCount int64
if err := db.Model(&model.RenterGrowthLedger{}).
Where("order_id = ? AND source = ?", order.ID, rentergrowth.SourceOrderRefunded).
Count(&revokeCount).Error; err != nil {
t.Fatalf("count revoke ledger failed: %v", err)
}
if revokeCount != 1 {
t.Fatalf("revoke ledger count = %d, want 1", revokeCount)
}
}
// TestPaymentNotifyResultStructure 测试支付回调结果结构
func TestNotifyResultHasRequiredFields(t *testing.T) {
result := NotifyResult{
+96 -15
View File
@@ -26,6 +26,7 @@ const (
MaxLevelPoints = 1_000_000_000
MaxLevelQuotaCent = 100_000_000
SourceOrderCompleted = "order_completed"
SourceOrderRefunded = "order_refunded"
SourceAdminAdjustment = "admin_adjustment"
)
@@ -243,7 +244,7 @@ func CalculateDiscountCent(originalRentCent int64, platformFeeCent int64, discou
return discountCent
}
func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent int64, source string) error {
func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualPureCoinCent int64, source string) error {
if tx == nil || order == nil || order.ID == 0 || order.RenterID == 0 {
return nil
}
@@ -271,10 +272,12 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i
if err != nil {
return err
}
order.GrowthPointsBasisCent = maxInt64(actualPureCoinCent, 0)
order.GrowthPointsPerYuan = cfg.PointsPerYuan
if !cfg.Enabled {
return nil
}
points := PointsForRent(actualRentCent, cfg.PointsPerYuan)
points := PointsForPureCoin(actualPureCoinCent, cfg.PointsPerYuan)
if points <= 0 {
return nil
}
@@ -293,16 +296,18 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i
}
now := time.Now()
ledger := model.RenterGrowthLedger{
UserID: order.RenterID,
OrderID: &order.ID,
Points: points,
BeforePoints: beforePoints,
AfterPoints: afterPoints,
BeforeLevel: beforeLevel.Code,
AfterLevel: afterLevel.Code,
Source: source,
Remark: "租号订单完成发放成长积分",
CreatedAt: now,
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,
}
if err := tx.Create(&ledger).Error; err != nil {
return err
@@ -312,14 +317,90 @@ func AwardOrderCompleted(tx *gorm.DB, order *model.RentalOrder, actualRentCent i
return nil
}
func PointsForRent(actualRentCent int64, pointsPerYuan int64) int64 {
if actualRentCent <= 0 {
// RevokeOrderCompleted 在已完成订单被全额人工退款后扣回该单已发放的成长积分。
func RevokeOrderCompleted(tx *gorm.DB, order *model.RentalOrder) error {
if tx == nil || order == nil || order.ID == 0 || order.RenterID == 0 {
return nil
}
var existing model.RenterGrowthLedger
err := tx.Where("order_id = ? AND source = ?", order.ID, SourceOrderRefunded).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) {
return nil
}
return err
}
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 {
return err
}
beforePoints := maxInt64(user.RenterGrowthPoints, 0)
revokedPoints := points
if revokedPoints > beforePoints {
revokedPoints = beforePoints
}
beforeLevel := LevelForPoints(cfg, beforePoints)
afterPoints := beforePoints - revokedPoints
afterLevel := LevelForPoints(cfg, afterPoints)
if !cfg.Enabled {
beforeLevel = LevelRule{Code: DefaultLevelCode, Name: DefaultLevelName}
afterLevel = beforeLevel
}
user.RenterGrowthPoints = afterPoints
user.RenterGrowthLevel = afterLevel.Code
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(),
}).Error
}
func PointsForPureCoin(actualPureCoinCent int64, pointsPerYuan int64) int64 {
if actualPureCoinCent <= 0 {
return 0
}
if pointsPerYuan <= 0 {
pointsPerYuan = DefaultPointsPerYuan
}
return (actualRentCent / 100) * pointsPerYuan
return (actualPureCoinCent / 100) * pointsPerYuan
}
// PointsForRent 保留旧调用兼容,积分基数现统一解释为实际纯币金额。
func PointsForRent(actualPureCoinCent int64, pointsPerYuan int64) int64 {
return PointsForPureCoin(actualPureCoinCent, pointsPerYuan)
}
func LevelForPoints(cfg Config, points int64) LevelRule {
@@ -159,6 +159,9 @@ func TestAwardOrderCompletedUpdatesUserAndIsIdempotent(t *testing.T) {
if order.GrowthPointsAwarded != 20 || order.GrowthPointsAwardedAt == nil {
t.Fatalf("order awarded = %d at %v, want 20 with time", order.GrowthPointsAwarded, order.GrowthPointsAwardedAt)
}
if order.GrowthPointsBasisCent != 2000 || order.GrowthPointsPerYuan != 1 {
t.Fatalf("order points basis = %d x %d, want 2000 x 1", order.GrowthPointsBasisCent, order.GrowthPointsPerYuan)
}
var savedUser model.User
if err := db.First(&savedUser, user.ID).Error; err != nil {
t.Fatalf("find user failed: %v", err)
@@ -186,4 +189,72 @@ func TestAwardOrderCompletedUpdatesUserAndIsIdempotent(t *testing.T) {
if ledgerCount != 1 {
t.Fatalf("ledger count = %d, want 1", ledgerCount)
}
if err := RevokeOrderCompleted(db, &order); err != nil {
t.Fatalf("RevokeOrderCompleted() error = %v", err)
}
if err := RevokeOrderCompleted(db, &order); err != nil {
t.Fatalf("second RevokeOrderCompleted() error = %v", err)
}
if err := db.First(&savedUser, user.ID).Error; err != nil {
t.Fatalf("find user after revoke failed: %v", err)
}
if savedUser.RenterGrowthPoints != 290 || savedUser.RenterGrowthLevel != DefaultLevelCode {
t.Fatalf("user growth after revoke = %d/%s, want 290/%s", savedUser.RenterGrowthPoints, savedUser.RenterGrowthLevel, DefaultLevelCode)
}
if err := db.Model(&model.RenterGrowthLedger{}).Where("order_id = ?", order.ID).Count(&ledgerCount).Error; err != nil {
t.Fatalf("count ledger after revoke failed: %v", err)
}
if ledgerCount != 2 {
t.Fatalf("ledger count after revoke = %d, want 2", ledgerCount)
}
}
func TestRevokeOrderCompletedRecordsIdempotencyWhenUserHasNoPoints(t *testing.T) {
db := database.NewTestDB()
if err := db.AutoMigrate(
&model.User{},
&model.RentalOrder{},
&model.SystemConfig{},
&model.RenterGrowthLedger{},
); err != nil {
t.Fatalf("migrate failed: %v", err)
}
user := model.User{Phone: "13800008889", RenterGrowthLevel: DefaultLevelCode}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user failed: %v", err)
}
order := model.RentalOrder{
OrderNo: "RG202607260002",
RenterID: user.ID,
GrowthPointsAwarded: 20,
GrowthPointsBasisCent: 2000,
GrowthPointsPerYuan: 1,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
}
if err := RevokeOrderCompleted(db, &order); err != nil {
t.Fatalf("RevokeOrderCompleted() error = %v", err)
}
if err := db.Model(&model.User{}).Where("id = ?", user.ID).Update("renter_growth_points", 100).Error; err != nil {
t.Fatalf("add later points failed: %v", err)
}
if err := RevokeOrderCompleted(db, &order); err != nil {
t.Fatalf("repeat RevokeOrderCompleted() error = %v", err)
}
var saved model.User
if err := db.First(&saved, user.ID).Error; err != nil {
t.Fatalf("find user failed: %v", err)
}
if saved.RenterGrowthPoints != 100 {
t.Fatalf("later points after repeated callback = %d, want 100", saved.RenterGrowthPoints)
}
var ledger model.RenterGrowthLedger
if err := db.Where("order_id = ? AND source = ?", order.ID, SourceOrderRefunded).First(&ledger).Error; err != nil {
t.Fatalf("find revoke ledger failed: %v", err)
}
if ledger.Points != 0 {
t.Fatalf("revoke ledger points = %d, want 0", ledger.Points)
}
}
@@ -0,0 +1,39 @@
-- +goose Up
ALTER TABLE rental_orders
ADD COLUMN pure_coin_original_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '下单时租客纯币原价快照(分)' AFTER rent_discount_amount_cent,
ADD COLUMN extra_item_original_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '下单时额外物品原价快照(分)' AFTER pure_coin_original_amount_cent,
ADD COLUMN actual_coin_consumed_m DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '最终确认哈夫币消耗(M)' AFTER extra_item_original_amount_cent,
ADD COLUMN actual_pure_coin_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '最终实际纯币原价(分)' AFTER actual_coin_consumed_m,
ADD COLUMN actual_pure_coin_discount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '最终实际纯币等级优惠(分)' AFTER actual_pure_coin_amount_cent,
ADD COLUMN growth_points_basis_cent BIGINT NOT NULL DEFAULT 0 COMMENT '成长积分计算纯币基数(分)' AFTER renter_discount_bps,
ADD COLUMN growth_points_per_yuan BIGINT NOT NULL DEFAULT 0 COMMENT '成长积分发放倍率' AFTER growth_points_basis_cent;
ALTER TABLE order_checkouts
ADD COLUMN pure_coin_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '方案实际纯币原价(分)' AFTER deposit_amount_cent,
ADD COLUMN pure_coin_discount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '方案实际纯币等级优惠(分)' AFTER pure_coin_amount_cent,
ADD COLUMN pure_coin_payable_cent BIGINT NOT NULL DEFAULT 0 COMMENT '方案实际折后纯币金额(分)' AFTER pure_coin_discount_cent;
ALTER TABLE renter_growth_ledger
ADD COLUMN basis_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '积分计算纯币基数(分)' AFTER points,
ADD COLUMN points_per_yuan BIGINT NOT NULL DEFAULT 0 COMMENT '积分发放倍率' AFTER basis_amount_cent;
-- +goose Down
ALTER TABLE order_checkouts
DROP COLUMN pure_coin_payable_cent,
DROP COLUMN pure_coin_discount_cent,
DROP COLUMN pure_coin_amount_cent;
ALTER TABLE renter_growth_ledger
DROP COLUMN points_per_yuan,
DROP COLUMN basis_amount_cent;
ALTER TABLE rental_orders
DROP COLUMN growth_points_per_yuan,
DROP COLUMN growth_points_basis_cent,
DROP COLUMN actual_pure_coin_discount_cent,
DROP COLUMN actual_pure_coin_amount_cent,
DROP COLUMN actual_coin_consumed_m,
DROP COLUMN extra_item_original_amount_cent,
DROP COLUMN pure_coin_original_amount_cent;