fix(invitation): 邀请返利改用实际结算利润并修正提号基准

This commit is contained in:
yml2213
2026-10-29 11:49:08 +08:00
parent e57c9df6f4
commit 86fbc05e9b
7 changed files with 277 additions and 13 deletions
@@ -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
}
@@ -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
@@ -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)
@@ -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
}
@@ -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)
}
})
}
}
+17 -2
View File
@@ -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
@@ -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) {
}
})
}