diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index 10a250b..35ae953 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -135,6 +135,11 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r if err := tx.Save(&order).Error; err != nil { return err } + if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 { + if err := distribution.ApplyRefundTx(tx, order.ID, refundAmountCent); err != nil { + return err + } + } if order.Status == "completed" { if actualSettlement == nil { return ErrInvalidDispute @@ -153,11 +158,6 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r ) } } - if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 { - if err := distribution.ApplyRefundTx(tx, order.ID, refundAmountCent); err != nil { - return err - } - } if err := tx.Save(&listing).Error; err != nil { return err } diff --git a/backend/internal/modules/invitation/repository.go b/backend/internal/modules/invitation/repository.go index a15d868..358addb 100644 --- a/backend/internal/modules/invitation/repository.go +++ b/backend/internal/modules/invitation/repository.go @@ -369,6 +369,14 @@ func (r *Repository) processOrderInvitationRebate(ctx context.Context, tx *gorm. orderGrossProfitCent := order.RentAmountCent - order.OwnerRentAmountCent if opts.GrossProfitCent != nil { orderGrossProfitCent = *opts.GrossProfitCent + } else { + // 正常结账、仲裁等终态都已落定实际结账单;未显式传基数时优先按实际平台利润, + // 避免再次用订单创建时的预收毛利快照给零消耗结账发返利。 + var committedCheckout model.OrderCheckout + if err := tx.Where("order_id = ? AND status IN ?", order.ID, []string{"accepted", "settled"}). + Order("id DESC").First(&committedCheckout).Error; err == nil { + orderGrossProfitCent = committedCheckout.PlatformFeeCent + } } if orderGrossProfitCent <= 0 { return nil diff --git a/backend/internal/modules/invitation/repository_test.go b/backend/internal/modules/invitation/repository_test.go index 1108274..bd2fba9 100644 --- a/backend/internal/modules/invitation/repository_test.go +++ b/backend/internal/modules/invitation/repository_test.go @@ -20,6 +20,7 @@ func setupTestDB(t *testing.T) *gorm.DB { &model.InvitationRebateConfig{}, &model.InvitationRebateLedger{}, &model.RentalOrder{}, + &model.OrderCheckout{}, &model.DistributionSite{}, &model.DistributionOrderParty{}, &model.WalletAccount{}, @@ -262,6 +263,57 @@ func TestProcessOrderInvitationRebateUsesActualProfitBasis(t *testing.T) { } } +func TestProcessOrderInvitationRebateFallsBackToCommittedCheckoutProfit(t *testing.T) { + db := setupTestDB(t) + inviter := model.User{Phone: "13800000021", RealnameStatus: "verified", Status: "active"} + if err := db.Create(&inviter).Error; err != nil { + t.Fatalf("创建邀请人失败: %v", err) + } + seller := model.User{Phone: "13800000022", RealnameStatus: "verified", Status: "active"} + if err := db.Create(&seller).Error; err != nil { + t.Fatalf("创建卖家失败: %v", err) + } + verifiedAt := time.Now().AddDate(0, 0, -2).Truncate(time.Second) + if err := db.Create(&model.UserInvitation{ + InviterID: inviter.ID, InviteeID: seller.ID, Status: StatusActive, VerifiedAt: &verifiedAt, + }).Error; err != nil { + t.Fatalf("创建邀请关系失败: %v", err) + } + order := model.RentalOrder{ + OrderNo: fmt.Sprintf("ORD-COMMITTED-%d", time.Now().UnixNano()), + OwnerID: seller.ID, + RenterID: 99999, + Status: "completed", + SettlementStatus: "settled", + RentAmountCent: 10000, + OwnerRentAmountCent: 8000, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("创建订单失败: %v", err) + } + if err := db.Create(&model.OrderCheckout{ + OrderID: order.ID, + Status: "accepted", + RentAmountCent: 2000, + OwnerRentAmountCent: 1700, + PlatformFeeCent: 300, + }).Error; err != nil { + t.Fatalf("创建已确认结账单失败: %v", err) + } + + if err := NewRepository(db).ProcessOrderInvitationRebate(t.Context(), db, order.ID); err != nil { + t.Fatalf("返利处理失败: %v", err) + } + var ledger model.InvitationRebateLedger + if err := db.Where("order_id = ? AND trade_role = 'seller'", order.ID).First(&ledger).Error; err != nil { + t.Fatalf("查询返利流水失败: %v", err) + } + if ledger.OrderProfitBasisCent != 300 || ledger.SingleSideBasisCent != 150 || ledger.RebateAmountCent != 90 { + t.Fatalf("返利基数/单边/金额 = %d/%d/%d, want 300/150/90", + ledger.OrderProfitBasisCent, ledger.SingleSideBasisCent, ledger.RebateAmountCent) + } +} + func TestCrossSiteDistributionRebate(t *testing.T) { db := setupTestDB(t) repo := NewRepository(db) diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index dca64dd..3d14646 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -62,7 +62,13 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che return nil, err } } - _ = invitation.ProcessOrderInvitationRebateTx(tx, order.ID) + // 返利必须按本次实际结算的平台利润计算,不能用订单创建时的预收毛利快照。 + actualPlatformProfitCent := settlement.PlatformFeeCent + _ = invitation.ProcessOrderInvitationRebateWithOptionsTx(tx, order.ID, invitation.OrderRebateOptions{ + GrossProfitCent: &actualPlatformProfitCent, + RateReferenceAt: now, + SettledAt: now, + }) return refund, nil } diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index d7d0895..0f7e6d7 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -2754,3 +2754,149 @@ func TestHideForOwnerRemovesSellerViewOnly(t *testing.T) { t.Fatalf("隐藏只写时间,订单状态应保持完成,got hidden=%v status=%s", saved.OwnerHiddenAt, saved.Status) } } + +// 邀请返利必须以实际结账落定的平台利润为基数: +// 租客申报 0 哈夫币损耗、0 额外物品时,实际平台利润为 0,不得再按订单预收毛利返利。 +func TestCheckoutInvitationRebateUsesActualPlatformProfit(t *testing.T) { + db := setupOrderTestDB(t) + if err := database.AutoMigrateIfMissing(db, + &model.UserInvitation{}, + &model.InvitationRebateConfig{}, + &model.InvitationRebateLedger{}, + ); err != nil { + t.Fatalf("迁移邀请返利表失败: %v", err) + } + repo := NewRepository(db, Dependencies{ + RefundStarter: RefundStarterFunc(func(context.Context, uint64, int64, string, string) (string, error) { + return "refunding", nil + }), + }) + snapshot := `{"haf_coin_amount":1000000,"asset_summary":{"price_breakdown":{"buyer_ratio":1,"seller_ratio":1.5,"buyer_coin_base_price":100,"seller_coin_base_price":80}}}` + + cases := []struct { + name string + coinConsumedM float64 + wantPlatformCent int64 + wantBasisCent int64 + wantSingleCent int64 + wantRebateCent int64 + }{ + {name: "零消耗不产生返利", coinConsumedM: 0, wantPlatformCent: 0}, + {name: "部分消耗按实际利润", coinConsumedM: 0.1, wantPlatformCent: 333, wantBasisCent: 333, wantSingleCent: 166, wantRebateCent: 100}, + } + + for index, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + inviter := model.User{ + Phone: fmt.Sprintf("1391000%04d", index), + Nickname: "结算返利邀请人", + RealnameStatus: "verified", + Status: "active", + } + owner := model.User{Phone: fmt.Sprintf("1392000%04d", index), Nickname: "结算返利号主", Status: "active"} + renter := model.User{Phone: fmt.Sprintf("1393000%04d", index), Nickname: "结算返利租客", Status: "active"} + if err := db.Create(&inviter).Error; err != nil { + t.Fatalf("创建邀请人失败: %v", err) + } + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("创建号主失败: %v", err) + } + if err := db.Create(&renter).Error; err != nil { + t.Fatalf("创建租客失败: %v", err) + } + + verifiedAt := time.Now().AddDate(0, 0, -2).Truncate(time.Second) + if err := db.Create(&model.UserInvitation{ + InviterID: inviter.ID, + InviteeID: owner.ID, + InviteCode: fmt.Sprintf("actual%02d", index), + Status: "active", + VerifiedAt: &verifiedAt, + }).Error; err != nil { + t.Fatalf("创建邀请关系失败: %v", err) + } + + account := model.GameAccount{ + OwnerID: owner.ID, + ServerRegion: "国服", + LoginPlatform: "steam", + Title: "结算返利测试账号", + Status: "rented", + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("创建账号失败: %v", err) + } + listing := model.RentalListing{ + ListingNo: fmt.Sprintf("LST-REBATE-ACTUAL-%02d", index), + AccountID: account.ID, + OwnerID: owner.ID, + PriceCent: 10000, + Status: "rented", + ReviewStatus: "approved", + InTransaction: true, + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("创建上架记录失败: %v", err) + } + order := model.RentalOrder{ + OrderNo: fmt.Sprintf("ORD-REBATE-ACTUAL-%d-%02d", time.Now().UnixNano(), index), + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + RenterID: renter.ID, + Status: orderStatusRenting, + HandoffStatus: handoffStatusReceived, + EstimatedDurationHours: 24, + RentAmountCent: 10000, + RentOriginalAmountCent: 10000, + OwnerRentAmountCent: 8000, + PlatformFeeCent: 2000, + PureCoinOriginalAmountCent: 10000, + AccountSnapshot: datatypes.JSON([]byte(snapshot)), + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("创建订单失败: %v", err) + } + + if _, err := repo.SubmitCheckout(t.Context(), renter.ID, order.ID, SubmitCheckoutRequest{ + Content: "按实际消耗结账", + CoinConsumedM: tc.coinConsumedM, + }); err != nil { + t.Fatalf("提交结账失败: %v", err) + } + if err := repo.ConfirmCheckout(t.Context(), owner.ID, order.ID); err != nil { + t.Fatalf("确认结账失败: %v", err) + } + + var checkout model.OrderCheckout + if err := db.Where("order_id = ?", order.ID).Order("id DESC").First(&checkout).Error; err != nil { + t.Fatalf("读取结账单失败: %v", err) + } + if checkout.PlatformFeeCent != tc.wantPlatformCent { + t.Fatalf("实际平台利润 = %d, want %d", checkout.PlatformFeeCent, tc.wantPlatformCent) + } + + var ledgers []model.InvitationRebateLedger + if err := db.Where("source = ? AND order_id = ?", "rental_order", order.ID).Find(&ledgers).Error; err != nil { + t.Fatalf("读取返利流水失败: %v", err) + } + if tc.wantRebateCent == 0 { + if len(ledgers) != 0 { + t.Fatalf("零消耗结账不应发返利,实际 %d 条", len(ledgers)) + } + return + } + if len(ledgers) != 1 { + t.Fatalf("返利流水条数 = %d, want 1", len(ledgers)) + } + ledger := ledgers[0] + if ledger.OrderProfitBasisCent != tc.wantBasisCent || + ledger.SingleSideBasisCent != tc.wantSingleCent || + ledger.RebateAmountCent != tc.wantRebateCent { + t.Fatalf("返利基数/单边/金额 = %d/%d/%d, want %d/%d/%d", + ledger.OrderProfitBasisCent, ledger.SingleSideBasisCent, ledger.RebateAmountCent, + tc.wantBasisCent, tc.wantSingleCent, tc.wantRebateCent) + } + }) + } +} diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go index 2b1f07d..7df4bbd 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -308,8 +308,9 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete } // 邀请返利:线下提号不走正常订单结账流程,必须在这里补发号主(卖家)侧返利, - // 否则号主邀请人永远拿不到提号产生的分成。基数按提号利润对半(与租赁订单卖家侧口径一致)。 - if grossBasisCent := pickup.ProfitAmountCent; grossBasisCent > 0 { + // 否则号主邀请人永远拿不到提号产生的分成。 + // 基准取「网站加价」与「线下利润」的较小值,避免按虚高的单一利润口径多算。 + if grossBasisCent := pickupRebateGrossBasis(pickup.WebsiteProfitCent, pickup.ProfitAmountCent); grossBasisCent > 0 { sellerSiteID := uint64(0) if listing.DistributionSiteID != nil { sellerSiteID = *listing.DistributionSiteID @@ -1923,6 +1924,20 @@ func maxPickupCent(a, b int64) int64 { return b } +// pickupRebateGrossBasis 取网站加价与线下利润的较小值,负数按 0 处理。 +func pickupRebateGrossBasis(websiteProfitCent, offlineProfitCent int64) int64 { + if websiteProfitCent < 0 { + websiteProfitCent = 0 + } + if offlineProfitCent < 0 { + offlineProfitCent = 0 + } + if websiteProfitCent < offlineProfitCent { + return websiteProfitCent + } + return offlineProfitCent +} + func normalizePaging(page, pageSize int) (int, int) { if page < 1 { page = 1 diff --git a/backend/internal/modules/pickup/repository_test.go b/backend/internal/modules/pickup/repository_test.go index dc452cf..c5a1a1d 100644 --- a/backend/internal/modules/pickup/repository_test.go +++ b/backend/internal/modules/pickup/repository_test.go @@ -376,6 +376,22 @@ func TestRepositoryCompletePickupAwardsSellerInvitationRebate(t *testing.T) { } listing := seedAvailableListing(t, db, "LST-REBATE-001", "返利提号账号", false) + // 网站加价 40 元、线下利润 100 元:返利基准必须取较小的 40 元。 + priceSummary, err := json.Marshal(map[string]any{ + "price_breakdown": map[string]any{ + "seller_coin_base_price": 60, + "seller_total_price": 60, + "platform_markup_amount": 40, + "consumable_price": 0, + }, + }) + if err != nil { + t.Fatalf("构造价格快照失败: %v", err) + } + if err := db.Model(&model.GameAccount{}).Where("id = ?", listing.AccountID). + Update("asset_summary", datatypes.JSON(priceSummary)).Error; err != nil { + t.Fatalf("更新账号价格快照失败: %v", err) + } // 距提号完成 2 天 => 首月 60% 档;Truncate 到整秒避免 DATETIME(0) 四舍五入影响天数计算。 verifiedAt := time.Now().AddDate(0, 0, -2).Truncate(time.Second) if err := db.Create(&model.UserInvitation{ @@ -420,9 +436,9 @@ func TestRepositoryCompletePickupAwardsSellerInvitationRebate(t *testing.T) { if rebate.OrderNo != pickup.PickupNo { t.Fatalf("返利单据号 = %q, want %q", rebate.OrderNo, pickup.PickupNo) } - // 基数 = 提号利润 10000 对半 = 5000,首月 60% = 3000 - assertInt64(t, "提号返利单边基数", rebate.SingleSideBasisCent, 5000) - assertInt64(t, "提号返利金额", rebate.RebateAmountCent, 3000) + // 基数 = min(网站加价 4000, 线下利润 10000) 对半 = 2000,首月 60% = 1200 + assertInt64(t, "提号返利单边基数", rebate.SingleSideBasisCent, 2000) + assertInt64(t, "提号返利金额", rebate.RebateAmountCent, 1200) var wallet model.WalletLedger if err := db.Where("biz_type = ? AND biz_no = ?", "invite_rebate", pickup.PickupNo).First(&wallet).Error; err != nil { @@ -431,7 +447,29 @@ func TestRepositoryCompletePickupAwardsSellerInvitationRebate(t *testing.T) { if wallet.UserID != inviter.ID { t.Fatalf("返利入账用户 = %d, want %d", wallet.UserID, inviter.ID) } - assertInt64(t, "提号返利入账金额", wallet.AmountCent, 3000) + assertInt64(t, "提号返利入账金额", wallet.AmountCent, 1200) +} + +func TestPickupRebateGrossBasisTakesLowerProfit(t *testing.T) { + cases := []struct { + name string + websiteProfit int64 + offlineProfit int64 + wantGrossBasis int64 + }{ + {name: "网站加价更低", websiteProfit: 4000, offlineProfit: 10000, wantGrossBasis: 4000}, + {name: "线下利润更低", websiteProfit: 10000, offlineProfit: 4000, wantGrossBasis: 4000}, + {name: "线下利润为零", websiteProfit: 10000, offlineProfit: 0, wantGrossBasis: 0}, + {name: "负数归零", websiteProfit: -1, offlineProfit: 4000, wantGrossBasis: 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := pickupRebateGrossBasis(tc.websiteProfit, tc.offlineProfit); got != tc.wantGrossBasis { + t.Fatalf("pickupRebateGrossBasis(%d, %d) = %d, want %d", + tc.websiteProfit, tc.offlineProfit, got, tc.wantGrossBasis) + } + }) + } } func TestRepositoryCompletePlatformManagedPickupUsesOfflineSettlement(t *testing.T) { @@ -1145,4 +1183,3 @@ func TestRepositoryUpdateThirdPartyOrderNo(t *testing.T) { } }) } -