diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index 1245861..d3674b8 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -26,6 +26,9 @@ type RentalOrder struct { 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"` RentOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` RentDiscountAmountCent int64 `gorm:"not null;default:0" json:"-"` PureCoinOriginalAmountCent int64 `gorm:"not null;default:0" json:"-"` diff --git a/backend/internal/modules/distribution/lifecycle.go b/backend/internal/modules/distribution/lifecycle.go index b2b2c9d..ecd5fc2 100644 --- a/backend/internal/modules/distribution/lifecycle.go +++ b/backend/internal/modules/distribution/lifecycle.go @@ -186,7 +186,10 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error if err := tx.First(&rule, *order.DistributionRuleVersionID).Error; err != nil { return err } - markup := order.PlatformFeeCent + markup := order.ShelfMarkupCent + if markup < 0 { + markup = 0 + } result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: grossPaidCent, MarkupAmountCent: markup}) if err != nil { return err diff --git a/backend/internal/modules/distribution/lifecycle_test.go b/backend/internal/modules/distribution/lifecycle_test.go index 06923cb..89df585 100644 --- a/backend/internal/modules/distribution/lifecycle_test.go +++ b/backend/internal/modules/distribution/lifecycle_test.go @@ -279,7 +279,8 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) { OwnerID: 1, RenterID: 2, Status: "renting", - PlatformFeeCent: 300, + PlatformFeeCent: 999, + ShelfMarkupCent: 300, DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, } @@ -300,6 +301,9 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) { if financial.GrossPaidAmountCent != grossPaid || financial.SiteID != site.ID { t.Fatalf("unexpected financial snapshot: %+v", financial) } + if financial.MarkupAmountCent != 300 { + t.Fatalf("markup must come from shelf snapshot, got %d", financial.MarkupAmountCent) + } var parties []model.DistributionOrderParty if err := db.Where("order_id = ?", order.ID).Order("id ASC").Find(&parties).Error; err != nil || len(parties) != 2 { @@ -347,3 +351,64 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) { t.Fatalf("expected net paid %d, got %d", grossPaid-refundAmount, updatedFinancial.NetPaidAmountCent) } } + +func TestSnapshotPaidOrderTxAddsShelfMarkupToSeller(t *testing.T) { + db := distributionTestDB(t) + now := time.Now() + site := model.DistributionSite{SiteCode: "sell_markup", SiteName: "卖出加价站", Domain: "sell-markup.dist.com", Status: "active", DisplayAccountEnabled: true} + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + rule := model.DistributionRuleVersion{ + SiteID: &site.ID, Mode: ModeTwoPartyPlatformReceive, Version: 1, Status: "active", + PlatformReceiveRatio: 3.5, DistributorSellRatio: 4, + ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50, + EffectiveFrom: now.Add(-time.Hour), + } + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + order := model.RentalOrder{ + OrderNo: "ORD-MARKUP-SELL", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "renting", + PlatformFeeCent: 2000, ShelfMarkupCent: 10000, + DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, DistributionMode: ModeTwoPartyPlatformReceive, + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + if err := SnapshotPaidOrderTx(db, order.ID, 20000); err != nil { + t.Fatal(err) + } + var parties []model.DistributionOrderParty + if err := db.Where("order_id = ?", order.ID).Find(&parties).Error; err != nil { + t.Fatal(err) + } + var seller, platform *model.DistributionOrderParty + for i := range parties { + switch parties[i].PartyRole { + case "distributor_sell": + seller = &parties[i] + case "platform_receive": + platform = &parties[i] + } + } + if seller == nil || platform == nil { + t.Fatalf("missing parties: %+v", parties) + } + if seller.IncomeAmountCent < 10000 { + t.Fatalf("seller split must include 10000 markup, got %d", seller.IncomeAmountCent) + } + if platform.PartyID != 0 { + t.Fatalf("platform party_id = %d, want 0", platform.PartyID) + } + if err := db.Model(&order).Update("shelf_markup_cent", 0).Error; err != nil { + t.Fatal(err) + } + var financial model.DistributionOrderFinancial + if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil { + t.Fatal(err) + } + if financial.MarkupAmountCent != 10000 { + t.Fatalf("historical snapshot must keep markup, got %d", financial.MarkupAmountCent) + } +} diff --git a/backend/internal/modules/listing/handler_parse.go b/backend/internal/modules/listing/handler_parse.go index e6bf82a..10bb386 100644 --- a/backend/internal/modules/listing/handler_parse.go +++ b/backend/internal/modules/listing/handler_parse.go @@ -88,38 +88,38 @@ func parseAdminListQuery(c *gin.Context) (AdminListQuery, bool) { func parsePublicListQuery(c *gin.Context) PublicListQuery { query := PublicListQuery{ - Page: parsePositiveInt(c.DefaultQuery("page", "1"), 1), - PageSize: parsePositiveInt(c.DefaultQuery("page_size", "20"), 20), - Keyword: strings.TrimSpace(c.Query("keyword")), - Sort: strings.TrimSpace(c.Query("sort")), - Zone: strings.TrimSpace(c.Query("zone")), - Server: parseCSVQuery(c.Query("server")), - Region: parseCSVQuery(c.Query("region")), - LoginMethod: parseCSVQuery(firstNonEmpty(c.Query("login_method"), c.Query("login"))), - Rank: parseCSVQuery(c.Query("rank")), - Insurance: parseCSVQuery(c.Query("insurance")), - Stamina: parseCSVQuery(c.Query("stamina")), - Load: parseCSVQuery(c.Query("load")), - SkinGroup: parseCSVQuery(c.Query("skin_group")), - SkinName: parseCSVQuery(firstNonEmpty(c.Query("skin_name"), c.Query("skin"))), - MinCoin: parseOptionalFloat(c.Query("min_coin")), - MaxCoin: parseOptionalFloat(c.Query("max_coin")), - MinRatio: parseOptionalFloat(c.Query("min_ratio")), - MaxRatio: parseOptionalFloat(c.Query("max_ratio")), - MinPrice: parseOptionalFloat(c.Query("min_price")), - MaxPrice: parseOptionalFloat(c.Query("max_price")), - MinDeposit: parseOptionalFloat(c.Query("min_deposit")), - MaxDeposit: parseOptionalFloat(c.Query("max_deposit")), - MinTotal: parseOptionalFloat(c.Query("min_total")), - MaxTotal: parseOptionalFloat(c.Query("max_total")), - MinFireLevel: parseOptionalFloat(c.Query("min_fire_level")), - MaxFireLevel: parseOptionalFloat(c.Query("max_fire_level")), - MinDailyLoss: parseOptionalFloat(c.Query("min_daily_loss")), - MaxDailyLoss: parseOptionalFloat(c.Query("max_daily_loss")), + Page: parsePositiveInt(c.DefaultQuery("page", "1"), 1), + PageSize: parsePositiveInt(c.DefaultQuery("page_size", "20"), 20), + Keyword: strings.TrimSpace(c.Query("keyword")), + Sort: strings.TrimSpace(c.Query("sort")), + Zone: strings.TrimSpace(c.Query("zone")), + Server: parseCSVQuery(c.Query("server")), + Region: parseCSVQuery(c.Query("region")), + LoginMethod: parseCSVQuery(firstNonEmpty(c.Query("login_method"), c.Query("login"))), + Rank: parseCSVQuery(c.Query("rank")), + Insurance: parseCSVQuery(c.Query("insurance")), + Stamina: parseCSVQuery(c.Query("stamina")), + Load: parseCSVQuery(c.Query("load")), + SkinGroup: parseCSVQuery(c.Query("skin_group")), + SkinName: parseCSVQuery(firstNonEmpty(c.Query("skin_name"), c.Query("skin"))), + MinCoin: parseOptionalFloat(c.Query("min_coin")), + MaxCoin: parseOptionalFloat(c.Query("max_coin")), + MinRatio: parseOptionalFloat(c.Query("min_ratio")), + MaxRatio: parseOptionalFloat(c.Query("max_ratio")), + MinPrice: parseOptionalFloat(c.Query("min_price")), + MaxPrice: parseOptionalFloat(c.Query("max_price")), + MinDeposit: parseOptionalFloat(c.Query("min_deposit")), + MaxDeposit: parseOptionalFloat(c.Query("max_deposit")), + MinTotal: parseOptionalFloat(c.Query("min_total")), + MaxTotal: parseOptionalFloat(c.Query("max_total")), + MinFireLevel: parseOptionalFloat(c.Query("min_fire_level")), + MaxFireLevel: parseOptionalFloat(c.Query("max_fire_level")), + MinDailyLoss: parseOptionalFloat(c.Query("min_daily_loss")), + MaxDailyLoss: parseOptionalFloat(c.Query("max_daily_loss")), MinSecretKD: parseOptionalFloat(c.Query("min_secret_kd")), MaxSecretKD: parseOptionalFloat(c.Query("max_secret_kd")), ResourceRanges: parseResourceRanges(c), - DistributionSiteCode: strings.TrimSpace(firstNonEmpty(c.Query("distribution_site_code"), c.Query("site_code"))), + DistributionSiteCode: strings.TrimSpace(firstNonEmpty(c.GetHeader("X-Distribution-Site-Code"), c.Query("distribution_site_code"), c.Query("site_code"))), } if query.PageSize > 50 { query.PageSize = 50 diff --git a/backend/internal/modules/listing/handler_public.go b/backend/internal/modules/listing/handler_public.go index d877ca5..66f5ef6 100644 --- a/backend/internal/modules/listing/handler_public.go +++ b/backend/internal/modules/listing/handler_public.go @@ -3,6 +3,7 @@ package listing import ( "net/http" "strconv" + "strings" filemodule "hfb_sys/backend/internal/modules/file" "hfb_sys/backend/pkg/response" @@ -24,7 +25,8 @@ func (h *Handler) FindPublic(c *gin.Context) { if !ok { return } - item, err := h.service.FindPublic(c.Request.Context(), id) + siteCode := strings.TrimSpace(firstNonEmpty(c.GetHeader("X-Distribution-Site-Code"), c.Query("distribution_site_code"), c.Query("site_code"))) + item, err := h.service.FindPublic(c.Request.Context(), id, siteCode) if err != nil { writeListingError(c, err) return diff --git a/backend/internal/modules/listing/public_query.go b/backend/internal/modules/listing/public_query.go index 342bbcd..7b4b638 100644 --- a/backend/internal/modules/listing/public_query.go +++ b/backend/internal/modules/listing/public_query.go @@ -31,6 +31,9 @@ func (r *Repository) ListPublic(ctx context.Context, query PublicListQuery) (*Pu return nil, err } items := publicListings(rowsToDTO(rows)) + if err := r.applyShelfMarkup(ctx, query.DistributionSiteCode, items); err != nil { + return nil, err + } baseQuery := query baseQuery.Zone = "" items = filterPublicListings(items, baseQuery) @@ -228,15 +231,63 @@ func copyPublicZoneCounts(counts map[string]int64) map[string]int64 { return copied } -func (r *Repository) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) { +func (r *Repository) FindPublic(ctx context.Context, id uint64, siteCode string) (*ListingDTO, error) { dto, err := r.findDTO(ctx, "l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false) if err != nil { return nil, err } applyPublicListingURLs(dto) + markups, err := r.shelfMarkupByListingID(ctx, siteCode, []uint64{dto.ID}) + if err != nil { + return nil, err + } + dto.PriceCent += markups[dto.ID] return dto, nil } +func (r *Repository) applyShelfMarkup(ctx context.Context, siteCode string, items []ListingDTO) error { + if siteCode == "" || len(items) == 0 { + return nil + } + ids := make([]uint64, 0, len(items)) + for _, item := range items { + ids = append(ids, item.ID) + } + markups, err := r.shelfMarkupByListingID(ctx, siteCode, ids) + if err != nil { + return err + } + for i := range items { + items[i].PriceCent += markups[items[i].ID] + } + return nil +} + +func (r *Repository) shelfMarkupByListingID(ctx context.Context, siteCode string, listingIDs []uint64) (map[uint64]int64, error) { + result := make(map[uint64]int64, len(listingIDs)) + if siteCode == "" || len(listingIDs) == 0 { + return result, nil + } + type markupRow struct { + ListingID uint64 `gorm:"column:listing_id"` + MarkupAmountCent int64 `gorm:"column:markup_amount_cent"` + } + var rows []markupRow + if err := r.db.WithContext(ctx).Table("distribution_site_accounts AS dsa"). + Select("dsa.listing_id, COALESCE(dsa.markup_amount_cent, 0) AS markup_amount_cent"). + Joins("INNER JOIN distribution_sites AS ds ON ds.id = dsa.site_id AND ds.site_code = ? AND ds.status = 'active'", siteCode). + Where("dsa.display_status = 'enabled' AND dsa.listing_id IN ?", listingIDs). + Scan(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + if row.MarkupAmountCent > 0 { + result[row.ListingID] = row.MarkupAmountCent + } + } + return result, nil +} + func (r *Repository) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) { return r.FindPublicScreenshotKey(ctx, id, 0) } diff --git a/backend/internal/modules/listing/service_query.go b/backend/internal/modules/listing/service_query.go index 7f748c1..c49d09c 100644 --- a/backend/internal/modules/listing/service_query.go +++ b/backend/internal/modules/listing/service_query.go @@ -18,11 +18,11 @@ func (s *Service) ListMine(ctx context.Context, ownerID uint64, siteCode ...stri return s.repo.ListMine(ctx, ownerID, siteCode...) } -func (s *Service) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) { +func (s *Service) FindPublic(ctx context.Context, id uint64, siteCode string) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.FindPublic(ctx, id) + return s.repo.FindPublic(ctx, id, siteCode) } func (s *Service) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) { diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index cb45c13..1ec8251 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -348,8 +348,6 @@ func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) { } } - - func uint64Pointer(value uint64) *uint64 { return &value } @@ -703,7 +701,7 @@ func TestListPublicWithDistributionSiteCode(t *testing.T) { site := model.DistributionSite{SiteCode: "site_alpha", SiteName: "分销A", Domain: "a.com", Status: "active", DisplayAccountEnabled: true} db.Create(&site) - dsa := model.DistributionSiteAccount{SiteID: site.ID, AccountID: account1.ID, ListingID: l1.ID, DisplayStatus: "enabled"} + dsa := model.DistributionSiteAccount{SiteID: site.ID, AccountID: account1.ID, ListingID: l1.ID, DisplayStatus: "enabled", MarkupAmountCent: 10000} db.Create(&dsa) res, err := repo.ListPublic(context.Background(), PublicListQuery{ @@ -715,6 +713,24 @@ func TestListPublicWithDistributionSiteCode(t *testing.T) { if len(res.Items) != 1 || res.Items[0].ListingNo != "L101" { t.Fatalf("expected only L101, got %+v", res.Items) } + if res.Items[0].PriceCent != 11000 { + t.Fatalf("expected public price 11000 with markup, got %d", res.Items[0].PriceCent) + } + + detail, err := repo.FindPublic(context.Background(), l1.ID, "site_alpha") + if err != nil { + t.Fatalf("FindPublic err: %v", err) + } + if detail.PriceCent != 11000 { + t.Fatalf("expected detail price 11000 with markup, got %d", detail.PriceCent) + } + mainDetail, err := repo.FindPublic(context.Background(), l1.ID, "") + if err != nil { + t.Fatalf("FindPublic main err: %v", err) + } + if mainDetail.PriceCent != 1000 { + t.Fatalf("main site must keep original price, got %d", mainDetail.PriceCent) + } allRes, err := repo.ListPublic(context.Background(), PublicListQuery{}) if err != nil { @@ -946,5 +962,3 @@ func TestListMineSiteIsolation(t *testing.T) { t.Fatalf("expected 2 listings, got %d", len(allItems)) } } - - diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 4684416..30ab659 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -35,6 +35,9 @@ type OrderDTO struct { 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"` 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"` diff --git a/backend/internal/modules/order/handler_user.go b/backend/internal/modules/order/handler_user.go index b9aea93..a2fd473 100644 --- a/backend/internal/modules/order/handler_user.go +++ b/backend/internal/modules/order/handler_user.go @@ -19,6 +19,9 @@ func (h *Handler) Create(c *gin.Context) { response.BadRequest(c, "订单信息不完整") return } + if strings.TrimSpace(req.DistributionSiteCode) == "" { + req.DistributionSiteCode = strings.TrimSpace(c.GetHeader("X-Distribution-Site-Code")) + } item, err := h.service.Create(c.Request.Context(), userID, req) if err != nil { writeOrderError(c, err) diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 8798a9c..50542ce 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "hfb_sys/backend/internal/model" @@ -57,7 +58,15 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ return err } rentHours := estimateOrderDurationHours(snapshot) + shelfMarkupCent, markupSiteID, err := loadShelfMarkupTx(tx, req.DistributionSiteCode, listing.ID) + if err != nil { + return err + } pricing := buildOrderPricing(listing, account) + basePriceCent := listing.PriceCent + if shelfMarkupCent > 0 { + pricing.RentAmountCent += shelfMarkupCent + } distributionSite, distributionRule, err := distribution.ResolveOrderSourceTx(tx, req.DistributionSiteCode, listing.ID, account.ID, time.Now()) if err != nil { return err @@ -104,6 +113,9 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ DepositFreeManualQuotaCent: growthSnapshot.ManualDepositFreeQuotaCent, DepositFreeUsedBeforeCent: depositWaiver.UsedBeforeCent, PlatformFeeCent: pricing.PlatformFeeCent, + BasePriceCent: basePriceCent, + ShelfMarkupCent: shelfMarkupCent, + MarkupSiteID: markupSiteID, RentOriginalAmountCent: rentOriginalAmountCent, RentDiscountAmountCent: rentDiscountAmountCent, PureCoinOriginalAmountCent: pricing.PureCoinAmountCent, @@ -156,6 +168,32 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ return r.FindForUser(ctx, renterID, createdID) } +func loadShelfMarkupTx(tx *gorm.DB, siteCode string, listingID uint64) (int64, uint64, error) { + siteCode = strings.TrimSpace(siteCode) + if siteCode == "" || listingID == 0 { + return 0, 0, nil + } + var row struct { + SiteID uint64 `gorm:"column:site_id"` + Markup int64 `gorm:"column:markup"` + } + err := tx.Table("distribution_site_accounts AS dsa"). + Select("dsa.site_id, COALESCE(dsa.markup_amount_cent, 0) AS markup"). + Joins("INNER JOIN distribution_sites AS ds ON ds.id = dsa.site_id AND ds.site_code = ? AND ds.status = 'active'", siteCode). + Where("dsa.listing_id = ? AND dsa.display_status = 'enabled'", listingID). + Take(&row).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, 0, nil + } + return 0, 0, err + } + if row.Markup < 0 { + row.Markup = 0 + } + return row.Markup, row.SiteID, nil +} + func orderAccountSourceSnapshot(tx *gorm.DB, listingID uint64) (string, string, error) { var upload model.ListingUpload err := tx.Where("listing_id = ?", listingID).Order("id DESC").First(&upload).Error diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index 4ba8b57..3cd7818 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -92,6 +92,9 @@ func (row orderRow) toAdminDTO() OrderDTO { DepositFreeManualQuotaCent: row.DepositFreeManualQuotaCent, DepositFreeUsedBeforeCent: row.DepositFreeUsedBeforeCent, PlatformFeeCent: &platformFeeCent, + BasePriceCent: row.BasePriceCent, + ShelfMarkupCent: row.ShelfMarkupCent, + MarkupSiteID: row.MarkupSiteID, RentOriginalAmountCent: effectiveRentOriginalAmountCent(row.RentalOrder), RentDiscountAmountCent: row.RentDiscountAmountCent, PureCoinOriginalAmountCent: orderPureCoinOriginalAmountCent(row.RentalOrder), diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go index e7424c2..e98848f 100644 --- a/backend/internal/modules/order/pricing.go +++ b/backend/internal/modules/order/pricing.go @@ -290,8 +290,19 @@ func effectiveCheckoutDepositDeductCent(checkout model.OrderCheckout) int64 { return maxCent(checkout.OtherAmountCent, 0) } +func orderConsumptionPrepaidCent(order model.RentalOrder) int64 { + prepaid := order.RentAmountCent + if order.ShelfMarkupCent > 0 { + prepaid -= order.ShelfMarkupCent + if prepaid < 0 { + prepaid = 0 + } + } + return prepaid +} + func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent int64, coinConsumedM float64, depositDeductAmountCent int64) checkoutSettlement { - orderRentAmountCent := order.RentAmountCent + orderRentAmountCent := orderConsumptionPrepaidCent(order) orderOwnerRentAmountCent := order.OwnerRentAmountCent orderDepositAmountCent := order.DepositAmountCent diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 8a6f94f..7125c73 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -277,6 +277,99 @@ func TestRepositoryCreateAppliesRenterGrowthDiscount(t *testing.T) { } } +func TestCreateOrderSnapshotsShelfMarkup(t *testing.T) { + db := setupOrderTestDB(t) + if err := db.AutoMigrate(&model.DistributionSite{}, &model.DistributionSiteAccount{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}); err != nil { + t.Fatal(err) + } + repo := NewRepository(db) + now := time.Now() + + owner := model.User{Phone: "13800002101"} + renter := model.User{Phone: "13800002102"} + if err := db.Create(&owner).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&renter).Error; err != nil { + t.Fatal(err) + } + account := model.GameAccount{ + OwnerID: owner.ID, + ServerRegion: "国服", + LoginPlatform: "steam", + Title: "加价账号", + AssetSummary: datatypes.JSON([]byte(`{ + "price_breakdown": { + "seller_total_price": 80, + "platform_markup_amount": 20 + } + }`)), + } + if err := db.Create(&account).Error; err != nil { + t.Fatal(err) + } + listing := model.RentalListing{ + ListingNo: "L-MARKUP-1", + AccountID: account.ID, + OwnerID: owner.ID, + PriceCent: 10000, + DepositAmountCent: 1000, + Status: listingStatusPublished, + ReviewStatus: listingReviewStatusApproved, + } + if err := db.Create(&listing).Error; err != nil { + t.Fatal(err) + } + site := model.DistributionSite{SiteCode: "markup_site", SiteName: "加价站", Domain: "markup.example.com", Status: "active", DisplayAccountEnabled: true} + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.DistributionSiteAccount{ + SiteID: site.ID, AccountID: account.ID, ListingID: listing.ID, DisplayStatus: "enabled", MarkupAmountCent: 10000, + }).Error; err != nil { + t.Fatal(err) + } + rule := model.DistributionRuleVersion{ + SiteID: &site.ID, Mode: "two_party_platform_receive", Version: 1, Status: "active", + PlatformReceiveRatio: 3.5, DistributorSellRatio: 4, + ServiceFeeValue: 0, ServiceFeeUnit: "fixed_amount", PaymentFeeBPS: 50, + EffectiveFrom: now.Add(-time.Hour), + } + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + + dto, err := repo.Create(t.Context(), renter.ID, CreateRequest{ListingID: listing.ID, DistributionSiteCode: "markup_site"}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if dto.RentAmountCent == nil || *dto.RentAmountCent != 20000 { + t.Fatalf("paid rent = %v, want 20000", dto.RentAmountCent) + } + if dto.BasePriceCent != 10000 || dto.ShelfMarkupCent != 10000 || dto.MarkupSiteID != site.ID { + t.Fatalf("markup snapshot = base %d markup %d site %d", dto.BasePriceCent, dto.ShelfMarkupCent, dto.MarkupSiteID) + } + + var saved model.RentalOrder + if err := db.First(&saved, dto.ID).Error; err != nil { + t.Fatal(err) + } + if saved.OwnerRentAmountCent != 8000 || saved.PlatformFeeCent != 2000 { + t.Fatalf("owner/platform must stay on base price, got %d/%d", saved.OwnerRentAmountCent, saved.PlatformFeeCent) + } + + if err := db.Model(&model.DistributionSiteAccount{}).Where("listing_id = ?", listing.ID).Update("markup_amount_cent", 0).Error; err != nil { + t.Fatal(err) + } + var after model.RentalOrder + if err := db.First(&after, dto.ID).Error; err != nil { + t.Fatal(err) + } + if after.ShelfMarkupCent != 10000 || after.RentAmountCent != 20000 { + t.Fatalf("historical order must keep markup snapshot, got markup %d rent %d", after.ShelfMarkupCent, after.RentAmountCent) + } +} + // TestBuildOrderPricing 测试订单定价计算 func TestBuildOrderPricingBasic(t *testing.T) { listing := model.RentalListing{ diff --git a/backend/internal/modules/order/repository_test.go b/backend/internal/modules/order/repository_test.go index 0a6be77..9fdd3e9 100644 --- a/backend/internal/modules/order/repository_test.go +++ b/backend/internal/modules/order/repository_test.go @@ -118,6 +118,35 @@ func TestBuildOrderPricingDoesNotDiscountAllExtraItemOrder(t *testing.T) { } } +func TestCheckoutDoesNotRefundShelfMarkup(t *testing.T) { + order := model.RentalOrder{ + RentAmountCent: 20000, + OwnerRentAmountCent: 8000, + PlatformFeeCent: 2000, + BasePriceCent: 10000, + ShelfMarkupCent: 10000, + DepositAmountCent: 1000, + AccountSnapshot: datatypes.JSON([]byte(`{ + "haf_coin_amount": 100000000, + "asset_summary": { + "price_breakdown": { + "buyer_coin_base_price": 100, + "seller_coin_base_price": 80, + "consumable_price": 0 + } + } + }`)), + } + settlement := calculateCheckoutSettlement(order, 0, 100, 0) + if settlement.RentRefundCent != 0 { + t.Fatalf("full consume must not refund shelf markup, rent refund = %d", settlement.RentRefundCent) + } + zeroUse := calculateCheckoutSettlement(order, 0, 0, 0) + if zeroUse.RentRefundCent != 10000 { + t.Fatalf("unused listing refunds base price only, got %d want 10000", zeroUse.RentRefundCent) + } +} + func TestCalculateCheckoutSettlementRefundsUnusedRent(t *testing.T) { order := model.RentalOrder{ RentAmountCent: 38300, @@ -527,4 +556,3 @@ func TestApplyAdminOrderFiltersSmartRouting(t *testing.T) { }) } } - diff --git a/backend/migrations/000075_order_shelf_markup_snapshot.sql b/backend/migrations/000075_order_shelf_markup_snapshot.sql new file mode 100644 index 0000000..65f5b84 --- /dev/null +++ b/backend/migrations/000075_order_shelf_markup_snapshot.sql @@ -0,0 +1,58 @@ +-- +goose Up + +-- 下单时冻结货架加价,后续改价不影响历史订单。 +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_orders' AND COLUMN_NAME = 'base_price_cent') = 0, + 'ALTER TABLE rental_orders ADD COLUMN base_price_cent BIGINT NOT NULL DEFAULT 0 AFTER platform_fee_cent', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_orders' AND COLUMN_NAME = 'shelf_markup_cent') = 0, + 'ALTER TABLE rental_orders ADD COLUMN shelf_markup_cent BIGINT NOT NULL DEFAULT 0 AFTER base_price_cent', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_orders' AND COLUMN_NAME = 'markup_site_id') = 0, + 'ALTER TABLE rental_orders ADD COLUMN markup_site_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER shelf_markup_cent', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- +goose Down + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_orders' AND COLUMN_NAME = 'markup_site_id') > 0, + 'ALTER TABLE rental_orders DROP COLUMN markup_site_id', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_orders' AND COLUMN_NAME = 'shelf_markup_cent') > 0, + 'ALTER TABLE rental_orders DROP COLUMN shelf_markup_cent', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_orders' AND COLUMN_NAME = 'base_price_cent') > 0, + 'ALTER TABLE rental_orders DROP COLUMN base_price_cent', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt;