diff --git a/backend/internal/model/distribution.go b/backend/internal/model/distribution.go index cd1c99a..2730b9d 100644 --- a/backend/internal/model/distribution.go +++ b/backend/internal/model/distribution.go @@ -17,6 +17,7 @@ type DistributionSite struct { FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"` DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"` ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"` + ReceiveRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_ratio_delta"` TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"` TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"` SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"` diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index e325755..b6b94a8 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -16,6 +16,7 @@ type SiteDTO struct { FooterInfoJSON string `json:"footer_info_json"` DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"` ListingQrcodeURL string `json:"listing_qrcode_url"` + ReceiveRatioDelta float64 `json:"receive_ratio_delta"` TaxEnabled bool `json:"tax_enabled"` TaxRateBPS int64 `json:"tax_rate_bps"` SettlementHoldDays int `json:"settlement_hold_days"` @@ -24,32 +25,34 @@ type SiteDTO struct { } type CreateSiteRequest struct { - SiteCode string `json:"site_code" binding:"required,max=64"` - SiteName string `json:"site_name" binding:"required,max=128"` - Domain string `json:"domain" binding:"required,max=255"` - DisplayAccountEnabled *bool `json:"display_account_enabled"` - DefaultSupportGroupID *uint64 `json:"default_support_group_id"` - ListingQrcodeURL *string `json:"listing_qrcode_url"` - TaxEnabled *bool `json:"tax_enabled"` - TaxRateBPS *int64 `json:"tax_rate_bps"` - SettlementHoldDays *int `json:"settlement_hold_days"` + SiteCode string `json:"site_code" binding:"required,max=64"` + SiteName string `json:"site_name" binding:"required,max=128"` + Domain string `json:"domain" binding:"required,max=255"` + DisplayAccountEnabled *bool `json:"display_account_enabled"` + DefaultSupportGroupID *uint64 `json:"default_support_group_id"` + ListingQrcodeURL *string `json:"listing_qrcode_url"` + ReceiveRatioDelta *float64 `json:"receive_ratio_delta"` + TaxEnabled *bool `json:"tax_enabled"` + TaxRateBPS *int64 `json:"tax_rate_bps"` + SettlementHoldDays *int `json:"settlement_hold_days"` } type UpdateSiteRequest struct { - SiteName *string `json:"site_name"` - Domain *string `json:"domain"` - Status *string `json:"status"` - DisplayAccountEnabled *bool `json:"display_account_enabled"` - DefaultSupportGroupID *uint64 `json:"default_support_group_id"` - ListingQrcodeURL *string `json:"listing_qrcode_url"` - TaxEnabled *bool `json:"tax_enabled"` - TaxRateBPS *int64 `json:"tax_rate_bps"` - SettlementHoldDays *int `json:"settlement_hold_days"` - LogoURL *string `json:"logo_url"` - FaviconURL *string `json:"favicon_url"` - BannersJSON *string `json:"banners_json"` - AnnouncementsJSON *string `json:"announcements_json"` - FooterInfoJSON *string `json:"footer_info_json"` + SiteName *string `json:"site_name"` + Domain *string `json:"domain"` + Status *string `json:"status"` + DisplayAccountEnabled *bool `json:"display_account_enabled"` + DefaultSupportGroupID *uint64 `json:"default_support_group_id"` + ListingQrcodeURL *string `json:"listing_qrcode_url"` + ReceiveRatioDelta *float64 `json:"receive_ratio_delta"` + TaxEnabled *bool `json:"tax_enabled"` + TaxRateBPS *int64 `json:"tax_rate_bps"` + SettlementHoldDays *int `json:"settlement_hold_days"` + LogoURL *string `json:"logo_url"` + FaviconURL *string `json:"favicon_url"` + BannersJSON *string `json:"banners_json"` + AnnouncementsJSON *string `json:"announcements_json"` + FooterInfoJSON *string `json:"footer_info_json"` } type RuleDTO struct { @@ -244,6 +247,7 @@ type PublicSiteInfoDTO struct { AnnouncementsJSON string `json:"announcements_json"` FooterInfoJSON string `json:"footer_info_json"` DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"` + ReceiveRatioDelta float64 `json:"receive_ratio_delta"` } type UpdateAccountDisplayRequest struct { @@ -353,26 +357,29 @@ type OnboardSiteResponse struct { // ReceivedListingDTO 是分销站点收到的账号资产信息 type ReceivedListingDTO struct { - ListingID uint64 `json:"listing_id"` - ListingNo string `json:"listing_no"` - SiteID uint64 `json:"site_id"` - AccountID uint64 `json:"account_id"` - OwnerID uint64 `json:"owner_id"` - OwnerPhone string `json:"owner_phone"` - OwnerNickname string `json:"owner_nickname"` - Title string `json:"title"` - GameName string `json:"game_name"` - ServerRegion string `json:"server_region"` - LoginPlatform string `json:"login_platform"` - RankLevel string `json:"rank_level"` - HafCoinAmount int64 `json:"haf_coin_amount"` - PriceCent int64 `json:"price_cent"` - DepositAmountCent int64 `json:"deposit_amount_cent"` - Status string `json:"status"` - ReviewStatus string `json:"review_status"` - ReviewReason string `json:"review_reason"` - PublishedAt *time.Time `json:"published_at,omitempty"` - CreatedAt time.Time `json:"created_at"` + ListingID uint64 `json:"listing_id"` + ListingNo string `json:"listing_no"` + SiteID uint64 `json:"site_id"` + AccountID uint64 `json:"account_id"` + OwnerID uint64 `json:"owner_id"` + OwnerPhone string `json:"owner_phone"` + OwnerNickname string `json:"owner_nickname"` + Title string `json:"title"` + GameName string `json:"game_name"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + RankLevel string `json:"rank_level"` + HafCoinAmount int64 `json:"haf_coin_amount"` + SellerRatio float64 `json:"seller_ratio"` + SellerTotalPriceCent int64 `json:"seller_total_price_cent"` + BuyerRatio float64 `json:"buyer_ratio"` + PriceCent int64 `json:"price_cent"` + DepositAmountCent int64 `json:"deposit_amount_cent"` + Status string `json:"status"` + ReviewStatus string `json:"review_status"` + ReviewReason string `json:"review_reason"` + PublishedAt *time.Time `json:"published_at,omitempty"` + CreatedAt time.Time `json:"created_at"` } type ReceivedListingQuery struct { diff --git a/backend/internal/modules/distribution/handler_integration_test.go b/backend/internal/modules/distribution/handler_integration_test.go index dea0950..3ace8d1 100644 --- a/backend/internal/modules/distribution/handler_integration_test.go +++ b/backend/internal/modules/distribution/handler_integration_test.go @@ -68,6 +68,7 @@ func setupHandlerTestEnv(t *testing.T) *handlerTestEnv { siteProtected.POST("/sites/:id/withdrawals", h.ApplySiteWithdrawal) siteProtected.GET("/sites/:id/withdrawals", h.ListSiteWithdrawals) siteProtected.GET("/sites/:id/analytics", h.GetDashboardAnalytics) + siteProtected.PUT("/sites/:id", h.UpdateSite) siteProtected.PUT("/sites/:id/branding", h.UpdateSiteBranding) siteProtected.POST("/sites/:id/sync-accounts", h.SyncSiteAccounts) siteProtected.GET("/received-listings", h.ListReceivedListings) @@ -404,6 +405,33 @@ func TestHandler_SiteOperatorEndpoints(t *testing.T) { t.Fatalf("branding expected 200, got %d: %s", wBrand.Code, wBrand.Body.String()) } + delta := 2.5 + wRatio := env.request("PUT", fmt.Sprintf("/api/v1/distribution/sites/%d", siteA.ID), UpdateSiteRequest{ReceiveRatioDelta: &delta}, adminA.ID, nil) + if wRatio.Code != http.StatusOK { + t.Fatalf("receive ratio expected 200, got %d: %s", wRatio.Code, wRatio.Body.String()) + } + var ratioResp struct { + Code string `json:"code"` + Data SiteDTO `json:"data"` + } + json.Unmarshal(wRatio.Body.Bytes(), &ratioResp) + if ratioResp.Data.ReceiveRatioDelta != 2.5 { + t.Fatalf("receive_ratio_delta = %v, want 2.5", ratioResp.Data.ReceiveRatioDelta) + } + hackedName := "越权改名" + wHackName := env.request("PUT", fmt.Sprintf("/api/v1/distribution/sites/%d", siteA.ID), UpdateSiteRequest{SiteName: &hackedName}, adminA.ID, nil) + if wHackName.Code != http.StatusOK { + t.Fatalf("operator privileged field expected 200 with ignore, got %d: %s", wHackName.Code, wHackName.Body.String()) + } + json.Unmarshal(wHackName.Body.Bytes(), &ratioResp) + if ratioResp.Data.SiteName != siteA.SiteName { + t.Fatalf("operator must not rename site, got %q", ratioResp.Data.SiteName) + } + wCrossRatio := env.request("PUT", fmt.Sprintf("/api/v1/distribution/sites/%d", siteA.ID), UpdateSiteRequest{ReceiveRatioDelta: &delta}, adminB.ID, nil) + if wCrossRatio.Code != http.StatusForbidden { + t.Fatalf("cross-site receive ratio expected 403, got %d: %s", wCrossRatio.Code, wCrossRatio.Body.String()) + } + // 6. 同步主站商品货架 opOwner := model.User{Phone: "13800000077", Status: "active"} env.db.Create(&opOwner) diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 4123c24..b459f0c 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -20,6 +20,7 @@ import ( "github.com/redis/go-redis/v9" "golang.org/x/crypto/bcrypt" + "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -64,6 +65,13 @@ func normalizeSettlementHoldDays(days int) (int, error) { return days, nil } +func normalizeReceiveRatioDelta(delta float64) (float64, error) { + if delta < 0 { + return 0, ErrInvalidAmount + } + return listingpkg.NormalizeSiteReceiveRatioDelta(delta), nil +} + type Repository struct { db *gorm.DB redis *redis.Client @@ -256,6 +264,13 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si if req.ListingQrcodeURL != nil { row.ListingQrcodeURL = strings.TrimSpace(*req.ListingQrcodeURL) } + if req.ReceiveRatioDelta != nil { + delta, err := normalizeReceiveRatioDelta(*req.ReceiveRatioDelta) + if err != nil { + return nil, err + } + row.ReceiveRatioDelta = delta + } if req.TaxEnabled != nil { row.TaxEnabled = *req.TaxEnabled } @@ -293,6 +308,27 @@ func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req Upd if err := r.ensureSiteAccessWithDB(ctx, tx, adminID, row.ID); err != nil { return err } + globalAccess := adminID == 0 + if !globalAccess { + global, accessErr := r.hasGlobalDistributionAccessWithDB(ctx, tx, adminID) + if accessErr != nil { + return accessErr + } + globalAccess = global + } + if req.ReceiveRatioDelta != nil { + delta, err := normalizeReceiveRatioDelta(*req.ReceiveRatioDelta) + if err != nil { + return err + } + row.ReceiveRatioDelta = delta + } + if !globalAccess { + if err := tx.Save(&row).Error; err != nil { + return err + } + return nil + } if req.SiteName != nil { row.SiteName = strings.TrimSpace(*req.SiteName) } @@ -474,6 +510,7 @@ func siteDTO(row model.DistributionSite) SiteDTO { DisplayAccountEnabled: row.DisplayAccountEnabled, DefaultSupportGroupID: row.DefaultSupportGroupID, ListingQrcodeURL: row.ListingQrcodeURL, + ReceiveRatioDelta: row.ReceiveRatioDelta, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, SettlementHoldDays: row.SettlementHoldDays, @@ -2097,6 +2134,7 @@ func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string) BannersJSON: site.BannersJSON, AnnouncementsJSON: site.AnnouncementsJSON, FooterInfoJSON: site.FooterInfoJSON, + ReceiveRatioDelta: site.ReceiveRatioDelta, } if r.redis != nil { if bytes, err := json.Marshal(res); err == nil { @@ -2347,28 +2385,29 @@ func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID u } type receivedListingRow struct { - ID uint64 `gorm:"column:id"` - ListingNo string `gorm:"column:listing_no"` - AccountID uint64 `gorm:"column:account_id"` - OwnerID uint64 `gorm:"column:owner_id"` - OwnerUsername string `gorm:"column:owner_username"` - OwnerPhone string `gorm:"column:owner_phone"` - GameName string `gorm:"column:game_name"` - Title string `gorm:"column:title"` - ServerRegion string `gorm:"column:server_region"` - LoginPlatform string `gorm:"column:login_platform"` - RankLevel string `gorm:"column:rank_level"` - HafCoinAmount int64 `gorm:"column:haf_coin_amount"` - PriceCent int64 `gorm:"column:price_cent"` - DepositAmountCent int64 `gorm:"column:deposit_amount_cent"` - Status string `gorm:"column:status"` - ReviewStatus string `gorm:"column:review_status"` - ReviewReason string `gorm:"column:review_reason"` - InTransaction bool `gorm:"column:in_transaction"` - DistributionSiteID uint64 `gorm:"column:distribution_site_id"` - DisplayStatus string `gorm:"column:display_status"` - CreatedAt time.Time `gorm:"column:created_at"` - PublishedAt *time.Time `gorm:"column:published_at"` + ID uint64 `gorm:"column:id"` + ListingNo string `gorm:"column:listing_no"` + AccountID uint64 `gorm:"column:account_id"` + OwnerID uint64 `gorm:"column:owner_id"` + OwnerUsername string `gorm:"column:owner_username"` + OwnerPhone string `gorm:"column:owner_phone"` + GameName string `gorm:"column:game_name"` + Title string `gorm:"column:title"` + ServerRegion string `gorm:"column:server_region"` + LoginPlatform string `gorm:"column:login_platform"` + RankLevel string `gorm:"column:rank_level"` + HafCoinAmount int64 `gorm:"column:haf_coin_amount"` + AssetSummary datatypes.JSON `gorm:"column:asset_summary"` + PriceCent int64 `gorm:"column:price_cent"` + DepositAmountCent int64 `gorm:"column:deposit_amount_cent"` + Status string `gorm:"column:status"` + ReviewStatus string `gorm:"column:review_status"` + ReviewReason string `gorm:"column:review_reason"` + InTransaction bool `gorm:"column:in_transaction"` + DistributionSiteID uint64 `gorm:"column:distribution_site_id"` + DisplayStatus string `gorm:"column:display_status"` + CreatedAt time.Time `gorm:"column:created_at"` + PublishedAt *time.Time `gorm:"column:published_at"` } var rows []receivedListingRow @@ -2377,6 +2416,7 @@ func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID u COALESCE(u.nickname, '') AS owner_username, COALESCE(u.phone, '') AS owner_phone, ga.game_name, ga.title, ga.server_region, ga.login_platform, ga.rank_level, ga.haf_coin_amount, + ga.asset_summary, rl.price_cent, rl.deposit_amount_cent, rl.status, rl.review_status, rl.review_reason, rl.in_transaction, COALESCE(rl.distribution_site_id, 0) AS distribution_site_id, COALESCE(dsa.display_status, 'enabled') AS display_status, @@ -2396,27 +2436,31 @@ func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID u if r.DisplayStatus == "disabled" { status = "offline" } + pricing := listingpkg.ReceivePricingFromAssetSummary(r.AssetSummary) items = append(items, ReceivedListingDTO{ - ListingID: r.ID, - ListingNo: r.ListingNo, - SiteID: r.DistributionSiteID, - AccountID: r.AccountID, - OwnerID: r.OwnerID, - OwnerPhone: maskedPhone, - OwnerNickname: r.OwnerUsername, - Title: r.Title, - GameName: r.GameName, - ServerRegion: r.ServerRegion, - LoginPlatform: r.LoginPlatform, - RankLevel: r.RankLevel, - HafCoinAmount: r.HafCoinAmount, - PriceCent: r.PriceCent, - DepositAmountCent: r.DepositAmountCent, - Status: status, - ReviewStatus: r.ReviewStatus, - ReviewReason: r.ReviewReason, - PublishedAt: r.PublishedAt, - CreatedAt: r.CreatedAt, + ListingID: r.ID, + ListingNo: r.ListingNo, + SiteID: r.DistributionSiteID, + AccountID: r.AccountID, + OwnerID: r.OwnerID, + OwnerPhone: maskedPhone, + OwnerNickname: r.OwnerUsername, + Title: r.Title, + GameName: r.GameName, + ServerRegion: r.ServerRegion, + LoginPlatform: r.LoginPlatform, + RankLevel: r.RankLevel, + HafCoinAmount: r.HafCoinAmount, + SellerRatio: pricing.SellerRatio, + SellerTotalPriceCent: pricing.SellerTotalPriceCent, + BuyerRatio: pricing.BuyerRatio, + PriceCent: r.PriceCent, + DepositAmountCent: r.DepositAmountCent, + Status: status, + ReviewStatus: r.ReviewStatus, + ReviewReason: r.ReviewReason, + PublishedAt: r.PublishedAt, + CreatedAt: r.CreatedAt, }) } diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index 61739b4..f22ed5a 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -1,6 +1,7 @@ package distribution import ( + "encoding/json" "errors" "testing" "time" @@ -8,6 +9,7 @@ import ( "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" + "gorm.io/datatypes" "gorm.io/gorm" ) @@ -666,7 +668,19 @@ func TestDistributionBrandingAndMarkup(t *testing.T) { t.Fatalf("cleared listing_qrcode_url = %q, want empty", qrCleared.ListingQrcodeURL) } - // Verify PublicSiteInfo contains branding + delta := 3.5 + ratioUpdated, err := repo.UpdateSite(t.Context(), 0, site.ID, UpdateSiteRequest{ReceiveRatioDelta: &delta}) + if err != nil { + t.Fatalf("UpdateSite receive ratio failed: %v", err) + } + if ratioUpdated.ReceiveRatioDelta != 3.5 { + t.Fatalf("receive_ratio_delta = %v, want 3.5", ratioUpdated.ReceiveRatioDelta) + } + negative := -1.0 + if _, err := repo.UpdateSite(t.Context(), 0, site.ID, UpdateSiteRequest{ReceiveRatioDelta: &negative}); !errors.Is(err, ErrInvalidAmount) { + t.Fatalf("negative receive ratio err = %v, want ErrInvalidAmount", err) + } + pubInfo, err := repo.GetPublicSiteInfo(t.Context(), "brand_site", "") if err != nil { t.Fatalf("GetPublicSiteInfo failed: %v", err) @@ -674,6 +688,9 @@ func TestDistributionBrandingAndMarkup(t *testing.T) { if pubInfo.LogoURL != "https://cdn.example.com/logo.png" { t.Fatalf("expected LogoURL, got: %+v", pubInfo) } + if pubInfo.ReceiveRatioDelta != 3.5 { + t.Fatalf("public receive_ratio_delta = %v, want 3.5", pubInfo.ReceiveRatioDelta) + } faviconOnly := "https://cdn.example.com/favicon2.ico" partial, err := repo.UpdateSiteBranding(t.Context(), 0, site.ID, UpdateSiteBrandingRequest{FaviconURL: &faviconOnly}) @@ -746,12 +763,24 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) { t.Fatal(err) } + summary, err := json.Marshal(map[string]any{ + "price_breakdown": map[string]any{ + "seller_ratio": 43.0, + "seller_total_price": 232.6, + "buyer_ratio": 38.0, + }, + }) + if err != nil { + t.Fatal(err) + } ga := model.GameAccount{ OwnerID: user.ID, GameName: "delta_force", ServerRegion: "cn", LoginPlatform: "pc", Title: "满配三角洲账号", + HafCoinAmount: 100_000_000, + AssetSummary: datatypes.JSON(summary), } if err := db.Create(&ga).Error; err != nil { t.Fatal(err) @@ -786,6 +815,9 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) { if item.ListingNo != "RL-99001" || item.GameName != "delta_force" || item.OwnerNickname != "seller01" { t.Fatalf("unexpected received listing: %+v", item) } + if item.SellerRatio != 43 || item.SellerTotalPriceCent != 23260 || item.BuyerRatio != 38 { + t.Fatalf("expected main-site receive pricing, got ratio=%v price=%d buyer=%v", item.SellerRatio, item.SellerTotalPriceCent, item.BuyerRatio) + } if err := repo.UpdateReceivedListingStatus(t.Context(), 0, listing.ID, "inactive"); !errors.Is(err, ErrInvalidListingStatus) { t.Fatalf("inactive status err = %v", err) diff --git a/backend/internal/modules/listing/handler_user.go b/backend/internal/modules/listing/handler_user.go index 992057d..1130ea0 100644 --- a/backend/internal/modules/listing/handler_user.go +++ b/backend/internal/modules/listing/handler_user.go @@ -42,7 +42,7 @@ func (h *Handler) Update(c *gin.Context) { response.BadRequest(c, listingBindErrorMessage(err)) return } - item, err := h.service.Update(c.Request.Context(), ownerID, id, req) + item, err := h.service.Update(c.Request.Context(), ownerID, id, req, strings.TrimSpace(c.GetHeader("X-Distribution-Site-Code"))) if err != nil { writeListingError(c, err) return diff --git a/backend/internal/modules/listing/receive_pricing.go b/backend/internal/modules/listing/receive_pricing.go new file mode 100644 index 0000000..b515d07 --- /dev/null +++ b/backend/internal/modules/listing/receive_pricing.go @@ -0,0 +1,377 @@ +package listing + +import ( + "context" + "encoding/json" + "math" + "strings" + + "hfb_sys/backend/internal/modules/systemconfig" + + "gorm.io/datatypes" +) + +const ( + maxAcceleratedSaleRatioIncrement = 10.0 + maxSiteReceiveRatioDelta = 20.0 + minSiteReceiveRatio = 0.01 +) + +// ReceivePricingView 是收号定价快照,供分销收号管理等读侧展示。 +type ReceivePricingView struct { + SellerRatio float64 + SellerTotalPriceCent int64 + BuyerRatio float64 +} + +// ReceivePricingFromAssetSummary 读取账号资产里的主站收号/挂牌比例。 +func ReceivePricingFromAssetSummary(raw datatypes.JSON) ReceivePricingView { + summary := decodeAssetSummary(raw) + breakdown := ensurePriceBreakdown(summary) + sellerTotal := readSummaryNumber(breakdown["seller_total_price"]) + return ReceivePricingView{ + SellerRatio: readSummaryNumber(breakdown["seller_ratio"]), + SellerTotalPriceCent: yuanToCent(sellerTotal), + BuyerRatio: readSummaryNumber(breakdown["buyer_ratio"]), + } +} + +func (s *Service) applyMainSiteReceivePricing(ctx context.Context, req *CreateRequest, receiveRatioDelta float64, onDistributionSite bool) error { + if req == nil { + return nil + } + options, err := s.publishRatioOptions(ctx) + if err != nil { + return err + } + config, err := s.externalSalePriceConfig(ctx) + if err != nil { + return err + } + applyMainSiteReceivePricing(req, options, config, receiveRatioDelta, onDistributionSite) + return nil +} + +func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.PublishOptionsDTO, config salePriceConfig, receiveRatioDelta float64, onDistributionSite bool) { + if req == nil { + return + } + if req.AssetSummary == nil { + req.AssetSummary = map[string]any{} + } + coinM := coinMFromHafCoinAmount(req.HafCoinAmount) + referenceRatio := calculateSellerReferenceRatio(coinM, req.AssetSummary, options) + if referenceRatio <= 0 { + return + } + + breakdown := ensurePriceBreakdown(req.AssetSummary) + requestedRatio := readSummaryNumber(breakdown["seller_ratio"]) + if requestedRatio <= 0 { + requestedRatio = readSummaryNumber(breakdown["accelerated_sale_ratio"]) + } + sellerRatio := clampSaleRatio(referenceRatio, requestedRatio) + delta := NormalizeSiteReceiveRatioDelta(receiveRatioDelta) + if onDistributionSite { + sellerRatio = ApplySiteReceiveRatioDelta(referenceRatio, delta) + } + coinWan := coinM * 100 + sellerCoinBasePrice := roundMoney(coinWan / sellerRatio) + consumablePrice := consumableValue(req.AssetSummary) + sellerTotalPrice := roundMoney(sellerCoinBasePrice + consumablePrice) + dailyLoss := readSummaryNumber(req.AssetSummary["daily_loss_m"]) + pricing := calculateExternalPlatformPricing(coinM, sellerRatio, sellerCoinBasePrice, consumablePrice, config) + + breakdown["seller_reference_ratio"] = referenceRatio + breakdown["seller_ratio"] = sellerRatio + breakdown["seller_coin_base_price"] = sellerCoinBasePrice + breakdown["seller_total_price"] = sellerTotalPrice + breakdown["consumable_price"] = consumablePrice + breakdown["daily_loss_ratio_adjustment"] = calculateDailyLossRatioAdjustment(dailyLoss) + breakdown["accelerated_sale_ratio"] = sellerRatio + breakdown["site_receive_ratio_delta"] = delta + breakdown["buyer_coin_base_price"] = pricing.BuyerCoinBasePrice + breakdown["buyer_total_price"] = pricing.BuyerTotalPrice + breakdown["buyer_ratio"] = pricing.BuyerRatio + breakdown["platform_markup_amount"] = pricing.PlatformMarkupPrice + breakdown["platform_rule_type"] = pricing.RuleType + req.AssetSummary["price_breakdown"] = breakdown + req.AssetSummary["publish_ratio"] = pricing.BuyerRatio + req.PriceCent = yuanToCent(pricing.BuyerTotalPrice) +} + +// NormalizeSiteReceiveRatioDelta 将分站收号下调点数限制在 [0, 20]。 +func NormalizeSiteReceiveRatioDelta(delta float64) float64 { + if delta < 0 || math.IsNaN(delta) || math.IsInf(delta, 0) { + return 0 + } + if delta > maxSiteReceiveRatioDelta { + return maxSiteReceiveRatioDelta + } + return roundRatio(delta) +} + +// ApplySiteReceiveRatioDelta 在主站回收比例上限内下调分站收号比例。 +func ApplySiteReceiveRatioDelta(referenceRatio, delta float64) float64 { + if referenceRatio <= 0 { + return 0 + } + siteRatio := roundRatio(referenceRatio - NormalizeSiteReceiveRatioDelta(delta)) + if siteRatio < minSiteReceiveRatio { + return minSiteReceiveRatio + } + if siteRatio > referenceRatio { + return referenceRatio + } + return siteRatio +} + +func (s *Service) publishRatioOptions(ctx context.Context) (systemconfig.PublishOptionsDTO, error) { + options := systemconfig.DefaultPublishOptions() + if s == nil || s.config == nil { + return options, nil + } + value, err := s.config.FindValue(ctx, publishOptionsConfigKey) + if err != nil { + return options, err + } + if strings.TrimSpace(value) == "" { + return options, nil + } + var parsed systemconfig.PublishOptionsDTO + if err := json.Unmarshal([]byte(value), &parsed); err != nil { + return options, nil + } + if len(parsed.RatioConfig.InsuranceBaseRatios) == 0 { + parsed.RatioConfig.InsuranceBaseRatios = options.RatioConfig.InsuranceBaseRatios + } + if len(parsed.RatioConfig.ConfigItems) == 0 { + parsed.RatioConfig.ConfigItems = options.RatioConfig.ConfigItems + } + if len(parsed.RatioConfig.CoinCorrections) == 0 { + parsed.RatioConfig.CoinCorrections = options.RatioConfig.CoinCorrections + } + if len(parsed.LevelOptions) == 0 { + parsed.LevelOptions = options.LevelOptions + } + if len(parsed.SkinGroups) == 0 { + parsed.SkinGroups = options.SkinGroups + } + return parsed, nil +} + +func calculateSellerReferenceRatio(coinM float64, summary map[string]any, options systemconfig.PublishOptionsDTO) float64 { + if coinM <= 0 || summary == nil { + return 0 + } + insurance := readSummaryString(summary["season_insurance"]) + stamina := readSummaryString(summary["stamina_level"]) + load := readSummaryString(summary["load_level"]) + if insurance == "" || stamina == "" || load == "" { + return 0 + } + baseRatio := insuranceBaseRatio(options.RatioConfig.InsuranceBaseRatios, insurance) + if baseRatio <= 0 { + return 0 + } + dailyLoss := readSummaryNumber(summary["daily_loss_m"]) + return baseRatio + + calculateRatioConfigPenalty(options, summary, stamina, load) + + coinCorrection(options.RatioConfig.CoinCorrections, coinM) + + calculateDailyLossRatioAdjustment(dailyLoss) +} + +func clampSaleRatio(referenceRatio, requestedRatio float64) float64 { + maxRatio := roundRatio(referenceRatio + maxAcceleratedSaleRatioIncrement) + if requestedRatio <= 0 { + return referenceRatio + } + if requestedRatio < referenceRatio { + return referenceRatio + } + if requestedRatio > maxRatio { + return maxRatio + } + return roundRatio(requestedRatio) +} + +func insuranceBaseRatio(items []systemconfig.PublishInsuranceBaseRatio, insurance string) float64 { + for _, item := range items { + if item.Insurance == insurance { + return item.Ratio + } + } + return 0 +} + +func calculateRatioConfigPenalty(options systemconfig.PublishOptionsDTO, summary map[string]any, stamina, load string) float64 { + selectedSkins := flattenSkinNames(summary) + penalty := 0.0 + for _, item := range options.RatioConfig.ConfigItems { + if ratioConfigItemMatched(item, options, selectedSkins, stamina, load) { + continue + } + penalty += item.MissingPenalty + } + return penalty +} + +func ratioConfigItemMatched( + item systemconfig.PublishRatioConfigItem, + options systemconfig.PublishOptionsDTO, + selectedSkins []string, + stamina, load string, +) bool { + switch item.Kind { + case "skin_group": + return hasSelectedSkinGroup(options.SkinGroups, item.GroupKey, selectedSkins) + case "max_stamina": + return isMaxLevel(stamina, options.LevelOptions) + case "max_load": + return isMaxLevel(load, options.LevelOptions) + default: + return false + } +} + +func hasSelectedSkinGroup(groups []systemconfig.PublishOptionGroup, groupKey string, selectedSkins []string) bool { + if groupKey == "" || len(selectedSkins) == 0 { + return false + } + for _, group := range groups { + if group.Key != groupKey { + continue + } + allowed := make(map[string]struct{}, len(group.Options)) + for _, skin := range group.Options { + allowed[skin] = struct{}{} + } + for _, skin := range selectedSkins { + if _, ok := allowed[skin]; ok { + return true + } + } + } + return false +} + +func isMaxLevel(value string, levelOptions []string) bool { + current := readLevelNumber(value) + maxLevel := 0 + for _, option := range levelOptions { + if n := readLevelNumber(option); n > maxLevel { + maxLevel = n + } + } + if current > 0 && maxLevel > 0 { + return current >= maxLevel + } + if len(levelOptions) == 0 { + return false + } + return value == levelOptions[len(levelOptions)-1] +} + +func readLevelNumber(value string) int { + digits := "" + for _, r := range value { + if r >= '0' && r <= '9' { + digits += string(r) + } + } + if digits == "" { + return 0 + } + n := 0 + for _, r := range digits { + n = n*10 + int(r-'0') + } + return n +} + +func coinCorrection(items []systemconfig.PublishCoinCorrection, coinM float64) float64 { + bestThreshold := -1.0 + correction := 0.0 + for _, item := range items { + if coinM > item.ThresholdM && item.ThresholdM >= bestThreshold { + bestThreshold = item.ThresholdM + correction = item.Correction + } + } + return correction +} + +func calculateDailyLossRatioAdjustment(dailyLossM float64) float64 { + if dailyLossM <= 0 { + return 0 + } + adjustment := (dailyLossM - 10) / 10 + if adjustment < 0 { + return 0 + } + floored := float64(int(adjustment)) + if floored > 4 { + return 4 + } + return floored +} + +func coinMFromHafCoinAmount(amount int64) float64 { + if amount <= 0 { + return 0 + } + return float64(amount) / 1_000_000 +} + +func flattenSkinNames(summary map[string]any) []string { + if summary == nil { + return nil + } + raw, ok := summary["skin_groups"] + if !ok || raw == nil { + return nil + } + var names []string + switch groups := raw.(type) { + case map[string]any: + for _, value := range groups { + names = append(names, stringSlice(value)...) + } + case map[string][]string: + for _, value := range groups { + names = append(names, value...) + } + case map[string][]any: + for _, value := range groups { + names = append(names, stringSlice(value)...) + } + } + return names +} + +func stringSlice(value any) []string { + switch items := value.(type) { + case []string: + return items + case []any: + result := make([]string, 0, len(items)) + for _, item := range items { + text := strings.TrimSpace(readSummaryString(item)) + if text != "" { + result = append(result, text) + } + } + return result + default: + return nil + } +} + +func readSummaryString(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + default: + return "" + } +} diff --git a/backend/internal/modules/listing/receive_pricing_test.go b/backend/internal/modules/listing/receive_pricing_test.go new file mode 100644 index 0000000..9f92dee --- /dev/null +++ b/backend/internal/modules/listing/receive_pricing_test.go @@ -0,0 +1,117 @@ +package listing + +import ( + "testing" + + "hfb_sys/backend/internal/modules/systemconfig" +) + +func fullReceivePricingRequest(sellerRatio float64) CreateRequest { + return CreateRequest{ + HafCoinAmount: 100_000_000, + PriceCent: 100, + AssetSummary: map[string]any{ + "season_insurance": "3*3", + "stamina_level": "7级", + "load_level": "7级", + "daily_loss_m": 10, + "price_breakdown": map[string]any{ + "seller_ratio": sellerRatio, + "seller_total_price": 1.0, + "buyer_total_price": 1.0, + }, + }, + } +} + +func TestCalculateSellerReferenceRatioUsesMainSiteConfig(t *testing.T) { + options := systemconfig.DefaultPublishOptions() + summary := map[string]any{ + "season_insurance": "3*3", + "stamina_level": "7级", + "load_level": "7级", + "daily_loss_m": 10, + } + got := calculateSellerReferenceRatio(100, summary, options) + if got != 43 { + t.Fatalf("reference ratio = %v, want 43 (base 40 + missing skin penalties 3)", got) + } +} + +func TestApplyMainSiteReceivePricingOverwritesClientRatio(t *testing.T) { + req := fullReceivePricingRequest(10) + applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, false) + breakdown := ensurePriceBreakdown(req.AssetSummary) + if readSummaryNumber(breakdown["seller_ratio"]) != 43 { + t.Fatalf("seller_ratio = %v, want 43", breakdown["seller_ratio"]) + } + if readSummaryNumber(breakdown["seller_reference_ratio"]) != 43 { + t.Fatalf("seller_reference_ratio = %v, want 43", breakdown["seller_reference_ratio"]) + } + if readSummaryNumber(breakdown["seller_coin_base_price"]) != 232.6 { + t.Fatalf("seller_coin_base_price = %v, want 232.6", breakdown["seller_coin_base_price"]) + } + if readSummaryNumber(breakdown["buyer_ratio"]) != 38 { + t.Fatalf("buyer_ratio = %v, want 38", breakdown["buyer_ratio"]) + } + if req.PriceCent != 26320 { + t.Fatalf("PriceCent = %d, want 26320", req.PriceCent) + } +} + +func TestApplyMainSiteReceivePricingKeepsAcceleratedRatioWithinMainSiteRange(t *testing.T) { + req := fullReceivePricingRequest(50) + applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, false) + breakdown := ensurePriceBreakdown(req.AssetSummary) + if readSummaryNumber(breakdown["seller_ratio"]) != 50 { + t.Fatalf("accelerated seller_ratio = %v, want 50", breakdown["seller_ratio"]) + } + if readSummaryNumber(breakdown["seller_reference_ratio"]) != 43 { + t.Fatalf("seller_reference_ratio = %v, want 43", breakdown["seller_reference_ratio"]) + } +} + +func TestDistributionReceiveRatioCannotExceedMainSite(t *testing.T) { + req := fullReceivePricingRequest(50) + applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, true) + breakdown := ensurePriceBreakdown(req.AssetSummary) + if readSummaryNumber(breakdown["seller_ratio"]) != 43 { + t.Fatalf("distribution seller_ratio = %v, want main-site ceiling 43", breakdown["seller_ratio"]) + } +} + +func TestDistributionReceiveRatioDeltaLowersBelowMainSite(t *testing.T) { + req := fullReceivePricingRequest(50) + applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 3, true) + breakdown := ensurePriceBreakdown(req.AssetSummary) + if readSummaryNumber(breakdown["seller_ratio"]) != 40 { + t.Fatalf("distribution seller_ratio = %v, want 40 after lowering 3 points", breakdown["seller_ratio"]) + } + if readSummaryNumber(breakdown["seller_reference_ratio"]) != 43 { + t.Fatalf("seller_reference_ratio = %v, want 43", breakdown["seller_reference_ratio"]) + } + if readSummaryNumber(breakdown["site_receive_ratio_delta"]) != 3 { + t.Fatalf("site_receive_ratio_delta = %v, want 3", breakdown["site_receive_ratio_delta"]) + } +} + +func TestApplySiteReceiveRatioDeltaCapsAtMainSite(t *testing.T) { + if got := ApplySiteReceiveRatioDelta(43, -5); got != 43 { + t.Fatalf("negative delta must keep main-site ratio, got %v", got) + } + if got := ApplySiteReceiveRatioDelta(43, 3); got != 40 { + t.Fatalf("delta 3 = %v, want 40", got) + } + if got := NormalizeSiteReceiveRatioDelta(-2); got != 0 { + t.Fatalf("normalize negative = %v, want 0", got) + } +} + +func TestClampSaleRatioRejectsBelowReference(t *testing.T) { + if got := clampSaleRatio(43, 10); got != 43 { + t.Fatalf("clamp below reference = %v, want 43", got) + } + if got := clampSaleRatio(43, 80); got != 53 { + t.Fatalf("clamp above max = %v, want 53", got) + } +} diff --git a/backend/internal/modules/listing/service_mutation.go b/backend/internal/modules/listing/service_mutation.go index db0aa70..7859cb3 100644 --- a/backend/internal/modules/listing/service_mutation.go +++ b/backend/internal/modules/listing/service_mutation.go @@ -2,6 +2,7 @@ package listing import ( "context" + "strings" ) func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest, siteCode ...string) (*ListingDTO, error) { @@ -11,6 +12,14 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest, if !req.AgreedVirtualAssetSale || !req.AgreedSellerAgreement { return nil, ErrAgreementRequired } + effectiveSiteCode := "" + if len(siteCode) > 0 { + effectiveSiteCode = strings.TrimSpace(siteCode[0]) + } + delta, onDistribution := s.repo.ResolveReceiveRatioDelta(ctx, 0, effectiveSiteCode) + if err := s.applyMainSiteReceivePricing(ctx, &req, delta, onDistribution); err != nil { + return nil, err + } rules, err := s.publishRules(ctx) if err != nil { return nil, err @@ -29,17 +38,21 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest, if err != nil { return nil, err } - effectiveSiteCode := "" - if len(siteCode) > 0 { - effectiveSiteCode = siteCode[0] - } return s.repo.Create(ctx, ownerID, req, reviewRequired, cooldown, effectiveSiteCode) } -func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req UpdateRequest) (*ListingDTO, error) { +func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req UpdateRequest, siteCode ...string) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } + effectiveSiteCode := "" + if len(siteCode) > 0 { + effectiveSiteCode = strings.TrimSpace(siteCode[0]) + } + delta, onDistribution := s.repo.ResolveReceiveRatioDelta(ctx, id, effectiveSiteCode) + if err := s.applyMainSiteReceivePricing(ctx, &req, delta, onDistribution); err != nil { + return nil, err + } rules, err := s.publishRules(ctx) if err != nil { return nil, err diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index 106649d..b8c1943 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -1008,3 +1008,139 @@ func TestListMineSiteIsolation(t *testing.T) { t.Fatalf("expected 2 listings, got %d", len(allItems)) } } + +func TestCreateDistributionListingUsesMainSiteReceiveRatio(t *testing.T) { + db := database.NewTestDB() + if err := database.MigrateListingLifecycleTestSchema(db); err != nil { + t.Fatalf("migrate err: %v", err) + } + svc := NewService(NewRepository(db, nil), stubConfigReader{}) + + user := model.User{Phone: "18800000021", Nickname: "分销收号卖家", RealnameStatus: "verified", Status: "active"} + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } + site := model.DistributionSite{ + SiteCode: "dist_ratio_1", + SiteName: "收号比例站", + Domain: "ratio.example.com", + Status: "active", + DisplayAccountEnabled: true, + } + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + + dto, err := svc.Create(context.Background(), user.ID, CreateRequest{ + Title: "分销收号商品", + ServerRegion: "QQ", + LoginPlatform: "PC", + RankLevel: "钻石", + HafCoinAmount: 100_000_000, + PriceCent: 100, + DepositAmountCent: 15000, + ScreenshotURLS: []string{"https://example.com/ratio.png"}, + AssetSummary: map[string]any{ + "fire_level": 50, + "season_insurance": "3*3", + "stamina_level": "7级", + "load_level": "7级", + "daily_loss_m": 10, + "online_time": map[string]any{ + "start": "09:00", + "end": "23:00", + }, + "price_breakdown": map[string]any{ + "seller_ratio": 10.0, + "seller_total_price": 1.0, + "buyer_total_price": 1.0, + }, + }, + AgreedVirtualAssetSale: true, + AgreedSellerAgreement: true, + }, "dist_ratio_1") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + pricing := ReceivePricingFromAssetSummary(mustMarshalSummary(t, dto.AssetSummary)) + if pricing.SellerRatio != 43 { + t.Fatalf("distribution receive seller_ratio = %v, want 43 from main-site ratio_config", pricing.SellerRatio) + } + if pricing.SellerTotalPriceCent != 23260 { + t.Fatalf("seller_total_price_cent = %d, want 23260", pricing.SellerTotalPriceCent) + } + if dto.PriceCent != 26320 { + t.Fatalf("listing PriceCent = %d, want 26320 from main-site sale price", dto.PriceCent) + } + if dto.DistributionSiteCode != "dist_ratio_1" { + t.Fatalf("DistributionSiteCode = %s, want dist_ratio_1", dto.DistributionSiteCode) + } +} + +func TestCreateDistributionListingAppliesSiteReceiveRatioDelta(t *testing.T) { + db := database.NewTestDB() + if err := database.MigrateListingLifecycleTestSchema(db); err != nil { + t.Fatalf("migrate err: %v", err) + } + svc := NewService(NewRepository(db, nil), stubConfigReader{}) + + user := model.User{Phone: "18800000022", Nickname: "分销下调卖家", RealnameStatus: "verified", Status: "active"} + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } + site := model.DistributionSite{ + SiteCode: "dist_ratio_down", + SiteName: "下调收号站", + Domain: "ratio-down.example.com", + Status: "active", + DisplayAccountEnabled: true, + ReceiveRatioDelta: 3, + } + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + + dto, err := svc.Create(context.Background(), user.ID, CreateRequest{ + Title: "分销下调收号", + ServerRegion: "QQ", + LoginPlatform: "PC", + RankLevel: "钻石", + HafCoinAmount: 100_000_000, + PriceCent: 100, + DepositAmountCent: 15000, + ScreenshotURLS: []string{"https://example.com/ratio-down.png"}, + AssetSummary: map[string]any{ + "fire_level": 50, + "season_insurance": "3*3", + "stamina_level": "7级", + "load_level": "7级", + "daily_loss_m": 10, + "online_time": map[string]any{ + "start": "09:00", + "end": "23:00", + }, + "price_breakdown": map[string]any{ + "seller_ratio": 50.0, + }, + }, + AgreedVirtualAssetSale: true, + AgreedSellerAgreement: true, + }, "dist_ratio_down") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + pricing := ReceivePricingFromAssetSummary(mustMarshalSummary(t, dto.AssetSummary)) + if pricing.SellerRatio != 40 { + t.Fatalf("seller_ratio = %v, want 40 after site delta 3 below main-site 43", pricing.SellerRatio) + } +} + +func mustMarshalSummary(t *testing.T, summary map[string]any) []byte { + t.Helper() + raw, err := json.Marshal(summary) + if err != nil { + t.Fatal(err) + } + return raw +} diff --git a/backend/internal/modules/listing/site_receive.go b/backend/internal/modules/listing/site_receive.go new file mode 100644 index 0000000..a868301 --- /dev/null +++ b/backend/internal/modules/listing/site_receive.go @@ -0,0 +1,46 @@ +package listing + +import ( + "context" + "strings" + + "hfb_sys/backend/internal/model" +) + +func (r *Repository) ReceiveRatioDeltaBySiteCode(ctx context.Context, siteCode string) float64 { + siteCode = strings.TrimSpace(siteCode) + if r == nil || r.db == nil || siteCode == "" { + return 0 + } + var delta float64 + _ = r.db.WithContext(ctx).Model(&model.DistributionSite{}). + Select("COALESCE(receive_ratio_delta, 0)"). + Where("site_code = ? AND status = 'active'", siteCode). + Limit(1). + Scan(&delta) + return NormalizeSiteReceiveRatioDelta(delta) +} + +func (r *Repository) ResolveReceiveRatioDelta(ctx context.Context, listingID uint64, siteCode string) (float64, bool) { + if code := strings.TrimSpace(siteCode); code != "" { + return r.ReceiveRatioDeltaBySiteCode(ctx, code), true + } + if r == nil || r.db == nil || listingID == 0 { + return 0, false + } + var siteID *uint64 + if err := r.db.WithContext(ctx).Model(&model.RentalListing{}). + Select("distribution_site_id"). + Where("id = ?", listingID). + Limit(1). + Scan(&siteID).Error; err != nil || siteID == nil || *siteID == 0 { + return 0, false + } + var delta float64 + _ = r.db.WithContext(ctx).Model(&model.DistributionSite{}). + Select("COALESCE(receive_ratio_delta, 0)"). + Where("id = ? AND status = 'active'", *siteID). + Limit(1). + Scan(&delta) + return NormalizeSiteReceiveRatioDelta(delta), true +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 551f6e4..2e988cc 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -617,6 +617,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { distributionProtectedRoutes.GET("/auth/me", distributionHandler.Me) distributionProtectedRoutes.POST("/auth/logout", distributionHandler.Logout) distributionProtectedRoutes.POST("/sites/:id/sync-accounts", distributionHandler.SyncSiteAccounts) + distributionProtectedRoutes.PUT("/sites/:id", distributionHandler.UpdateSite) distributionProtectedRoutes.PUT("/sites/:id/branding", distributionHandler.UpdateSiteBranding) distributionProtectedRoutes.GET("/sites/:id/wallet", distributionHandler.GetSiteWallet) distributionProtectedRoutes.POST("/sites/:id/withdrawals", distributionHandler.ApplySiteWithdrawal) diff --git a/backend/migrations/000070_distribution_site_receive_ratio.sql b/backend/migrations/000070_distribution_site_receive_ratio.sql new file mode 100644 index 0000000..2eb6b2d --- /dev/null +++ b/backend/migrations/000070_distribution_site_receive_ratio.sql @@ -0,0 +1,10 @@ +-- +goose Up + +-- 分站收号比例相对主站的下调点数。主站回收比例为上限,分站只能下调(比例更低、号主底价更高),不能高于主站。 +ALTER TABLE distribution_sites + ADD COLUMN receive_ratio_delta DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '分站收号比例相对主站下调点数,0 表示与主站一致'; + +-- +goose Down + +ALTER TABLE distribution_sites + DROP COLUMN receive_ratio_delta; diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index 0fc3067..fff60ba 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -63,6 +63,12 @@ export function formatMoney(cent: number | undefined | null): string { return `¥${((cent ?? 0) / 100).toFixed(2)}` } +export function formatRatio(ratio: number | undefined | null): string { + const value = Number(ratio || 0) + if (!Number.isFinite(value) || value <= 0) return '--' + return `1:${value.toFixed(1)}` +} + export interface CurrentMember { admin_id: number username: string @@ -121,6 +127,8 @@ export interface Site { banners_json?: string announcements_json?: string footer_info_json?: string + listing_qrcode_url?: string + receive_ratio_delta?: number created_at: string } @@ -330,6 +338,11 @@ export async function listSites(): Promise { return extract(resp).items ?? [] } +export async function updateSite(siteID: number, payload: Partial>): Promise { + const resp = await api.put>(`/distribution/sites/${siteID}`, payload) + return extract(resp) +} + export interface DistributionSiteDefaults { default_settlement_hold_days: number } @@ -483,6 +496,9 @@ export interface ReceivedListing { login_platform: string rank_level: string haf_coin_amount: number + seller_ratio?: number + seller_total_price_cent?: number + buyer_ratio?: number price_cent: number deposit_amount_cent: number status: string diff --git a/distribution-admin/src/pages/Orders.tsx b/distribution-admin/src/pages/Orders.tsx index e03fd4d..4ec91a5 100644 --- a/distribution-admin/src/pages/Orders.tsx +++ b/distribution-admin/src/pages/Orders.tsx @@ -29,6 +29,7 @@ import { } from '@ant-design/icons' import { formatMoney, + formatRatio, listOrders, listSites, fetchReceivedListings, @@ -527,6 +528,15 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) { )} + {kind === 'receive' && ( + + )} + {kind === 'receive' ? ( ( +
+ + {formatRatio(row.seller_ratio)} + +
+ 收号价: {formatMoney(row.seller_total_price_cent)} +
+
+ ), + }, + { + title: '挂牌价 / 押金', width: 140, render: (_: unknown, row: ReceivedListing) => (
diff --git a/distribution-admin/src/pages/SiteSettings.tsx b/distribution-admin/src/pages/SiteSettings.tsx index 20ee8ac..9e5fd7a 100644 --- a/distribution-admin/src/pages/SiteSettings.tsx +++ b/distribution-admin/src/pages/SiteSettings.tsx @@ -8,6 +8,7 @@ import { Button, Form, Input, + InputNumber, Row, Col, Divider, @@ -30,6 +31,7 @@ import { import { listSites, updateSiteBranding, + updateSite, getMe, type Site, type SiteBrandingPayload, @@ -71,6 +73,8 @@ export default function SiteSettings() { icp: '', contact: '', }) + const [receiveRatioDelta, setReceiveRatioDelta] = useState(0) + const [savingRatio, setSavingRatio] = useState(false) const loadSite = useCallback(async (id: number) => { setLoading(true) @@ -100,6 +104,7 @@ export default function SiteSettings() { } catch { setFooterInfo({}) } + setReceiveRatioDelta(Math.max(0, found.receive_ratio_delta || 0)) } } catch { message.error('加载站点设置失败') @@ -181,6 +186,22 @@ export default function SiteSettings() { setAnnouncements(announcements.filter((_, i) => i !== index)) } + async function handleSaveReceiveRatio() { + if (!siteID) return + setSavingRatio(true) + try { + const updated = await updateSite(siteID, { receive_ratio_delta: receiveRatioDelta }) + setCurrentSite(updated) + setReceiveRatioDelta(Math.max(0, updated.receive_ratio_delta || 0)) + message.success('收号比例已保存,本站上传账号将按新比例计价') + } catch (error: unknown) { + const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message + message.error(respMessage || '保存收号比例失败') + } finally { + setSavingRatio(false) + } + } + async function handleSave() { if (!siteID) return setSaving(true) @@ -249,6 +270,35 @@ export default function SiteSettings() {
+ + +
+ + 本站收号比例 + + + 主站回收比例是上限。下调点数越大,号主底价越高、本站利润空间越大,但不能高于主站比例。0 表示与主站一致。 + +
+ + 相对主站下调 + setReceiveRatioDelta(Math.max(0, Number(value || 0)))} + addonAfter="点" + style={{ width: 160 }} + /> + + +
+
+ {/* Left Visual Form Customizer */} diff --git a/distribution-admin/src/pages/Sites.tsx b/distribution-admin/src/pages/Sites.tsx index 94f7595..f4ed68a 100644 --- a/distribution-admin/src/pages/Sites.tsx +++ b/distribution-admin/src/pages/Sites.tsx @@ -166,6 +166,16 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean render: (_: unknown, row: Site) => row.tax_enabled ? {row.tax_rate_bps / 100}% : 不征税, }, + { + title: '收号下调', + width: 120, + render: (_: unknown, row: Site) => + Number(row.receive_ratio_delta || 0) > 0 ? ( + -{Number(row.receive_ratio_delta).toFixed(1)} 点 + ) : ( + 与主站一致 + ), + }, { title: '创建时间', dataIndex: 'created_at', diff --git a/frontend/src/features/admin/api/adminDistribution.ts b/frontend/src/features/admin/api/adminDistribution.ts index 85166ea..af657b6 100644 --- a/frontend/src/features/admin/api/adminDistribution.ts +++ b/frontend/src/features/admin/api/adminDistribution.ts @@ -12,6 +12,7 @@ export interface DistributionSite { tax_rate_bps: number settlement_hold_days: number listing_qrcode_url?: string + receive_ratio_delta?: number created_at: string } @@ -152,6 +153,7 @@ export async function updateDistributionSite( | 'tax_rate_bps' | 'settlement_hold_days' | 'listing_qrcode_url' + | 'receive_ratio_delta' > > ) { diff --git a/frontend/src/features/admin/views/AdminDistributionSitesView.vue b/frontend/src/features/admin/views/AdminDistributionSitesView.vue index 679267e..e891b09 100644 --- a/frontend/src/features/admin/views/AdminDistributionSitesView.vue +++ b/frontend/src/features/admin/views/AdminDistributionSitesView.vue @@ -10,6 +10,7 @@ import { Clock, Picture, Upload, + Coin, } from '@element-plus/icons-vue' import { fetchDistributionSites, @@ -45,6 +46,14 @@ const settlementForm = reactive({ settlement_hold_days: defaultSettlementHoldDays.value, }) +const receiveRatioDialogVisible = ref(false) +const receiveRatioSubmitting = ref(false) +const receiveRatioForm = reactive({ + site_id: 0, + site_name: '', + receive_ratio_delta: 0, +}) + const qrDialogVisible = ref(false) const qrSubmitting = ref(false) const qrUploading = ref(false) @@ -77,6 +86,29 @@ async function handleSettlementSubmit() { } } +function handleOpenReceiveRatio(row: DistributionSite) { + receiveRatioForm.site_id = row.id + receiveRatioForm.site_name = row.site_name + receiveRatioForm.receive_ratio_delta = Number(row.receive_ratio_delta || 0) + receiveRatioDialogVisible.value = true +} + +async function handleReceiveRatioSubmit() { + receiveRatioSubmitting.value = true + try { + await updateDistributionSite(receiveRatioForm.site_id, { + receive_ratio_delta: Number(receiveRatioForm.receive_ratio_delta), + }) + ElMessage.success(`【${receiveRatioForm.site_name}】收号比例下调已更新`) + receiveRatioDialogVisible.value = false + await loadSites() + } catch (err) { + ElMessage.error(readError(err, '更新收号比例失败')) + } finally { + receiveRatioSubmitting.value = false + } +} + function handleOpenQrcode(row: DistributionSite) { qrForm.site_id = row.id qrForm.site_name = row.site_name @@ -257,8 +289,8 @@ onMounted(() => {

分销站点管理

- 开通分销站、配置结算冷却期与发布群固定二维码。利润按 35% 收号 / 40% 卖出 / 25% - 服务费切分。 + 开通分销站、配置结算冷却期、收号比例上限与发布群固定二维码。利润按 35% 收号 / 40% 卖出 / + 25% 服务费切分。

@@ -314,6 +346,17 @@ onMounted(() => { + + + - + + + + + + + + + + + 点(0-20) + + + + + siteStore.isDistribution) + const receiveRatioDelta = computed(() => Number(siteStore.receiveRatioDelta || 0)) const loading = ref(false) const uploading = ref(false) const suppressDraftSave = ref(false) @@ -89,6 +93,8 @@ export function usePublishForm(options: UsePublishFormOptions) { quantityModes, screenshotFiles, selectedSkins, + isDistribution, + receiveRatioDelta, }) // ========== 上号方式互斥 ========== @@ -911,6 +917,7 @@ export function usePublishForm(options: UsePublishFormOptions) { return { ...pricing, + isDistribution, dailyLossOptions, formatNumber, router, diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.css b/frontend/src/features/seller/views/MobileSellerListingCreateView.css index 6e6c8e0..2920bb1 100644 --- a/frontend/src/features/seller/views/MobileSellerListingCreateView.css +++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.css @@ -692,6 +692,14 @@ line-height: 1.5; } +.ratio-inherit-hint { + margin: 0; + color: #1677ff; + font-size: 11px; + font-weight: 700; + line-height: 1.5; +} + .ratio-mode-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue index 63aac55..1572957 100644 --- a/frontend/src/features/seller/views/MobileSellerListingCreateView.vue +++ b/frontend/src/features/seller/views/MobileSellerListingCreateView.vue @@ -49,6 +49,7 @@ const { priceConfig, fireLevelPlaceholder, dailyLossMAmount, + isDistribution, calculatedDefaultSaleRatio, maxAcceleratedSaleRatio, calculatedCoinBasePrice, @@ -694,11 +695,16 @@ const screenshotGuides: Record = {

- 参考比例 + {{ isDistribution ? '主站收号上限' : '参考比例' }} {{ calculatedDefaultSaleRatioText }}
-

{{ saleRatioRangeText }},比例越高出租速度越快,自行选择。

-
+

+ 本站可下调收号比例换取更高利润,但不能高于主站比例。当前按站点配置计算。 +

+

+ {{ saleRatioRangeText }}{{ isDistribution ? '。' : ',比例越高出租速度越快,自行选择。' }} +

+