feat(distribution): 分站可下调收号比例且不超过主站上限
让分销站能按自身能力下调收号比例扩大利润,同时把主站回收比例作为硬边界,避免分站把比例加得比主站更高。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,6 +17,7 @@ type DistributionSite struct {
|
|||||||
FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"`
|
FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"`
|
||||||
DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"`
|
DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"`
|
||||||
ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"`
|
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"`
|
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
|
||||||
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
|
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
|
||||||
SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"`
|
SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"`
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type SiteDTO struct {
|
|||||||
FooterInfoJSON string `json:"footer_info_json"`
|
FooterInfoJSON string `json:"footer_info_json"`
|
||||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
|
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
|
||||||
ListingQrcodeURL string `json:"listing_qrcode_url"`
|
ListingQrcodeURL string `json:"listing_qrcode_url"`
|
||||||
|
ReceiveRatioDelta float64 `json:"receive_ratio_delta"`
|
||||||
TaxEnabled bool `json:"tax_enabled"`
|
TaxEnabled bool `json:"tax_enabled"`
|
||||||
TaxRateBPS int64 `json:"tax_rate_bps"`
|
TaxRateBPS int64 `json:"tax_rate_bps"`
|
||||||
SettlementHoldDays int `json:"settlement_hold_days"`
|
SettlementHoldDays int `json:"settlement_hold_days"`
|
||||||
@@ -24,32 +25,34 @@ type SiteDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateSiteRequest struct {
|
type CreateSiteRequest struct {
|
||||||
SiteCode string `json:"site_code" binding:"required,max=64"`
|
SiteCode string `json:"site_code" binding:"required,max=64"`
|
||||||
SiteName string `json:"site_name" binding:"required,max=128"`
|
SiteName string `json:"site_name" binding:"required,max=128"`
|
||||||
Domain string `json:"domain" binding:"required,max=255"`
|
Domain string `json:"domain" binding:"required,max=255"`
|
||||||
DisplayAccountEnabled *bool `json:"display_account_enabled"`
|
DisplayAccountEnabled *bool `json:"display_account_enabled"`
|
||||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
||||||
ListingQrcodeURL *string `json:"listing_qrcode_url"`
|
ListingQrcodeURL *string `json:"listing_qrcode_url"`
|
||||||
TaxEnabled *bool `json:"tax_enabled"`
|
ReceiveRatioDelta *float64 `json:"receive_ratio_delta"`
|
||||||
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
TaxEnabled *bool `json:"tax_enabled"`
|
||||||
SettlementHoldDays *int `json:"settlement_hold_days"`
|
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
||||||
|
SettlementHoldDays *int `json:"settlement_hold_days"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateSiteRequest struct {
|
type UpdateSiteRequest struct {
|
||||||
SiteName *string `json:"site_name"`
|
SiteName *string `json:"site_name"`
|
||||||
Domain *string `json:"domain"`
|
Domain *string `json:"domain"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
DisplayAccountEnabled *bool `json:"display_account_enabled"`
|
DisplayAccountEnabled *bool `json:"display_account_enabled"`
|
||||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
||||||
ListingQrcodeURL *string `json:"listing_qrcode_url"`
|
ListingQrcodeURL *string `json:"listing_qrcode_url"`
|
||||||
TaxEnabled *bool `json:"tax_enabled"`
|
ReceiveRatioDelta *float64 `json:"receive_ratio_delta"`
|
||||||
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
TaxEnabled *bool `json:"tax_enabled"`
|
||||||
SettlementHoldDays *int `json:"settlement_hold_days"`
|
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
||||||
LogoURL *string `json:"logo_url"`
|
SettlementHoldDays *int `json:"settlement_hold_days"`
|
||||||
FaviconURL *string `json:"favicon_url"`
|
LogoURL *string `json:"logo_url"`
|
||||||
BannersJSON *string `json:"banners_json"`
|
FaviconURL *string `json:"favicon_url"`
|
||||||
AnnouncementsJSON *string `json:"announcements_json"`
|
BannersJSON *string `json:"banners_json"`
|
||||||
FooterInfoJSON *string `json:"footer_info_json"`
|
AnnouncementsJSON *string `json:"announcements_json"`
|
||||||
|
FooterInfoJSON *string `json:"footer_info_json"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuleDTO struct {
|
type RuleDTO struct {
|
||||||
@@ -244,6 +247,7 @@ type PublicSiteInfoDTO struct {
|
|||||||
AnnouncementsJSON string `json:"announcements_json"`
|
AnnouncementsJSON string `json:"announcements_json"`
|
||||||
FooterInfoJSON string `json:"footer_info_json"`
|
FooterInfoJSON string `json:"footer_info_json"`
|
||||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
|
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
|
||||||
|
ReceiveRatioDelta float64 `json:"receive_ratio_delta"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateAccountDisplayRequest struct {
|
type UpdateAccountDisplayRequest struct {
|
||||||
@@ -353,26 +357,29 @@ type OnboardSiteResponse struct {
|
|||||||
|
|
||||||
// ReceivedListingDTO 是分销站点收到的账号资产信息
|
// ReceivedListingDTO 是分销站点收到的账号资产信息
|
||||||
type ReceivedListingDTO struct {
|
type ReceivedListingDTO struct {
|
||||||
ListingID uint64 `json:"listing_id"`
|
ListingID uint64 `json:"listing_id"`
|
||||||
ListingNo string `json:"listing_no"`
|
ListingNo string `json:"listing_no"`
|
||||||
SiteID uint64 `json:"site_id"`
|
SiteID uint64 `json:"site_id"`
|
||||||
AccountID uint64 `json:"account_id"`
|
AccountID uint64 `json:"account_id"`
|
||||||
OwnerID uint64 `json:"owner_id"`
|
OwnerID uint64 `json:"owner_id"`
|
||||||
OwnerPhone string `json:"owner_phone"`
|
OwnerPhone string `json:"owner_phone"`
|
||||||
OwnerNickname string `json:"owner_nickname"`
|
OwnerNickname string `json:"owner_nickname"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
GameName string `json:"game_name"`
|
GameName string `json:"game_name"`
|
||||||
ServerRegion string `json:"server_region"`
|
ServerRegion string `json:"server_region"`
|
||||||
LoginPlatform string `json:"login_platform"`
|
LoginPlatform string `json:"login_platform"`
|
||||||
RankLevel string `json:"rank_level"`
|
RankLevel string `json:"rank_level"`
|
||||||
HafCoinAmount int64 `json:"haf_coin_amount"`
|
HafCoinAmount int64 `json:"haf_coin_amount"`
|
||||||
PriceCent int64 `json:"price_cent"`
|
SellerRatio float64 `json:"seller_ratio"`
|
||||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
SellerTotalPriceCent int64 `json:"seller_total_price_cent"`
|
||||||
Status string `json:"status"`
|
BuyerRatio float64 `json:"buyer_ratio"`
|
||||||
ReviewStatus string `json:"review_status"`
|
PriceCent int64 `json:"price_cent"`
|
||||||
ReviewReason string `json:"review_reason"`
|
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
Status string `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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 {
|
type ReceivedListingQuery struct {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ func setupHandlerTestEnv(t *testing.T) *handlerTestEnv {
|
|||||||
siteProtected.POST("/sites/:id/withdrawals", h.ApplySiteWithdrawal)
|
siteProtected.POST("/sites/:id/withdrawals", h.ApplySiteWithdrawal)
|
||||||
siteProtected.GET("/sites/:id/withdrawals", h.ListSiteWithdrawals)
|
siteProtected.GET("/sites/:id/withdrawals", h.ListSiteWithdrawals)
|
||||||
siteProtected.GET("/sites/:id/analytics", h.GetDashboardAnalytics)
|
siteProtected.GET("/sites/:id/analytics", h.GetDashboardAnalytics)
|
||||||
|
siteProtected.PUT("/sites/:id", h.UpdateSite)
|
||||||
siteProtected.PUT("/sites/:id/branding", h.UpdateSiteBranding)
|
siteProtected.PUT("/sites/:id/branding", h.UpdateSiteBranding)
|
||||||
siteProtected.POST("/sites/:id/sync-accounts", h.SyncSiteAccounts)
|
siteProtected.POST("/sites/:id/sync-accounts", h.SyncSiteAccounts)
|
||||||
siteProtected.GET("/received-listings", h.ListReceivedListings)
|
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())
|
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. 同步主站商品货架
|
// 6. 同步主站商品货架
|
||||||
opOwner := model.User{Phone: "13800000077", Status: "active"}
|
opOwner := model.User{Phone: "13800000077", Status: "active"}
|
||||||
env.db.Create(&opOwner)
|
env.db.Create(&opOwner)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
@@ -64,6 +65,13 @@ func normalizeSettlementHoldDays(days int) (int, error) {
|
|||||||
return days, nil
|
return days, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeReceiveRatioDelta(delta float64) (float64, error) {
|
||||||
|
if delta < 0 {
|
||||||
|
return 0, ErrInvalidAmount
|
||||||
|
}
|
||||||
|
return listingpkg.NormalizeSiteReceiveRatioDelta(delta), nil
|
||||||
|
}
|
||||||
|
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
redis *redis.Client
|
redis *redis.Client
|
||||||
@@ -256,6 +264,13 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
|
|||||||
if req.ListingQrcodeURL != nil {
|
if req.ListingQrcodeURL != nil {
|
||||||
row.ListingQrcodeURL = strings.TrimSpace(*req.ListingQrcodeURL)
|
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 {
|
if req.TaxEnabled != nil {
|
||||||
row.TaxEnabled = *req.TaxEnabled
|
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 {
|
if err := r.ensureSiteAccessWithDB(ctx, tx, adminID, row.ID); err != nil {
|
||||||
return err
|
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 {
|
if req.SiteName != nil {
|
||||||
row.SiteName = strings.TrimSpace(*req.SiteName)
|
row.SiteName = strings.TrimSpace(*req.SiteName)
|
||||||
}
|
}
|
||||||
@@ -474,6 +510,7 @@ func siteDTO(row model.DistributionSite) SiteDTO {
|
|||||||
DisplayAccountEnabled: row.DisplayAccountEnabled,
|
DisplayAccountEnabled: row.DisplayAccountEnabled,
|
||||||
DefaultSupportGroupID: row.DefaultSupportGroupID,
|
DefaultSupportGroupID: row.DefaultSupportGroupID,
|
||||||
ListingQrcodeURL: row.ListingQrcodeURL,
|
ListingQrcodeURL: row.ListingQrcodeURL,
|
||||||
|
ReceiveRatioDelta: row.ReceiveRatioDelta,
|
||||||
TaxEnabled: row.TaxEnabled,
|
TaxEnabled: row.TaxEnabled,
|
||||||
TaxRateBPS: row.TaxRateBPS,
|
TaxRateBPS: row.TaxRateBPS,
|
||||||
SettlementHoldDays: row.SettlementHoldDays,
|
SettlementHoldDays: row.SettlementHoldDays,
|
||||||
@@ -2097,6 +2134,7 @@ func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string)
|
|||||||
BannersJSON: site.BannersJSON,
|
BannersJSON: site.BannersJSON,
|
||||||
AnnouncementsJSON: site.AnnouncementsJSON,
|
AnnouncementsJSON: site.AnnouncementsJSON,
|
||||||
FooterInfoJSON: site.FooterInfoJSON,
|
FooterInfoJSON: site.FooterInfoJSON,
|
||||||
|
ReceiveRatioDelta: site.ReceiveRatioDelta,
|
||||||
}
|
}
|
||||||
if r.redis != nil {
|
if r.redis != nil {
|
||||||
if bytes, err := json.Marshal(res); err == 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 {
|
type receivedListingRow struct {
|
||||||
ID uint64 `gorm:"column:id"`
|
ID uint64 `gorm:"column:id"`
|
||||||
ListingNo string `gorm:"column:listing_no"`
|
ListingNo string `gorm:"column:listing_no"`
|
||||||
AccountID uint64 `gorm:"column:account_id"`
|
AccountID uint64 `gorm:"column:account_id"`
|
||||||
OwnerID uint64 `gorm:"column:owner_id"`
|
OwnerID uint64 `gorm:"column:owner_id"`
|
||||||
OwnerUsername string `gorm:"column:owner_username"`
|
OwnerUsername string `gorm:"column:owner_username"`
|
||||||
OwnerPhone string `gorm:"column:owner_phone"`
|
OwnerPhone string `gorm:"column:owner_phone"`
|
||||||
GameName string `gorm:"column:game_name"`
|
GameName string `gorm:"column:game_name"`
|
||||||
Title string `gorm:"column:title"`
|
Title string `gorm:"column:title"`
|
||||||
ServerRegion string `gorm:"column:server_region"`
|
ServerRegion string `gorm:"column:server_region"`
|
||||||
LoginPlatform string `gorm:"column:login_platform"`
|
LoginPlatform string `gorm:"column:login_platform"`
|
||||||
RankLevel string `gorm:"column:rank_level"`
|
RankLevel string `gorm:"column:rank_level"`
|
||||||
HafCoinAmount int64 `gorm:"column:haf_coin_amount"`
|
HafCoinAmount int64 `gorm:"column:haf_coin_amount"`
|
||||||
PriceCent int64 `gorm:"column:price_cent"`
|
AssetSummary datatypes.JSON `gorm:"column:asset_summary"`
|
||||||
DepositAmountCent int64 `gorm:"column:deposit_amount_cent"`
|
PriceCent int64 `gorm:"column:price_cent"`
|
||||||
Status string `gorm:"column:status"`
|
DepositAmountCent int64 `gorm:"column:deposit_amount_cent"`
|
||||||
ReviewStatus string `gorm:"column:review_status"`
|
Status string `gorm:"column:status"`
|
||||||
ReviewReason string `gorm:"column:review_reason"`
|
ReviewStatus string `gorm:"column:review_status"`
|
||||||
InTransaction bool `gorm:"column:in_transaction"`
|
ReviewReason string `gorm:"column:review_reason"`
|
||||||
DistributionSiteID uint64 `gorm:"column:distribution_site_id"`
|
InTransaction bool `gorm:"column:in_transaction"`
|
||||||
DisplayStatus string `gorm:"column:display_status"`
|
DistributionSiteID uint64 `gorm:"column:distribution_site_id"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at"`
|
DisplayStatus string `gorm:"column:display_status"`
|
||||||
PublishedAt *time.Time `gorm:"column:published_at"`
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
PublishedAt *time.Time `gorm:"column:published_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows []receivedListingRow
|
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.nickname, '') AS owner_username,
|
||||||
COALESCE(u.phone, '') AS owner_phone,
|
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.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.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,
|
rl.in_transaction, COALESCE(rl.distribution_site_id, 0) AS distribution_site_id,
|
||||||
COALESCE(dsa.display_status, 'enabled') AS display_status,
|
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" {
|
if r.DisplayStatus == "disabled" {
|
||||||
status = "offline"
|
status = "offline"
|
||||||
}
|
}
|
||||||
|
pricing := listingpkg.ReceivePricingFromAssetSummary(r.AssetSummary)
|
||||||
items = append(items, ReceivedListingDTO{
|
items = append(items, ReceivedListingDTO{
|
||||||
ListingID: r.ID,
|
ListingID: r.ID,
|
||||||
ListingNo: r.ListingNo,
|
ListingNo: r.ListingNo,
|
||||||
SiteID: r.DistributionSiteID,
|
SiteID: r.DistributionSiteID,
|
||||||
AccountID: r.AccountID,
|
AccountID: r.AccountID,
|
||||||
OwnerID: r.OwnerID,
|
OwnerID: r.OwnerID,
|
||||||
OwnerPhone: maskedPhone,
|
OwnerPhone: maskedPhone,
|
||||||
OwnerNickname: r.OwnerUsername,
|
OwnerNickname: r.OwnerUsername,
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
GameName: r.GameName,
|
GameName: r.GameName,
|
||||||
ServerRegion: r.ServerRegion,
|
ServerRegion: r.ServerRegion,
|
||||||
LoginPlatform: r.LoginPlatform,
|
LoginPlatform: r.LoginPlatform,
|
||||||
RankLevel: r.RankLevel,
|
RankLevel: r.RankLevel,
|
||||||
HafCoinAmount: r.HafCoinAmount,
|
HafCoinAmount: r.HafCoinAmount,
|
||||||
PriceCent: r.PriceCent,
|
SellerRatio: pricing.SellerRatio,
|
||||||
DepositAmountCent: r.DepositAmountCent,
|
SellerTotalPriceCent: pricing.SellerTotalPriceCent,
|
||||||
Status: status,
|
BuyerRatio: pricing.BuyerRatio,
|
||||||
ReviewStatus: r.ReviewStatus,
|
PriceCent: r.PriceCent,
|
||||||
ReviewReason: r.ReviewReason,
|
DepositAmountCent: r.DepositAmountCent,
|
||||||
PublishedAt: r.PublishedAt,
|
Status: status,
|
||||||
CreatedAt: r.CreatedAt,
|
ReviewStatus: r.ReviewStatus,
|
||||||
|
ReviewReason: r.ReviewReason,
|
||||||
|
PublishedAt: r.PublishedAt,
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package distribution
|
package distribution
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/database"
|
"hfb_sys/backend/internal/database"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -666,7 +668,19 @@ func TestDistributionBrandingAndMarkup(t *testing.T) {
|
|||||||
t.Fatalf("cleared listing_qrcode_url = %q, want empty", qrCleared.ListingQrcodeURL)
|
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", "")
|
pubInfo, err := repo.GetPublicSiteInfo(t.Context(), "brand_site", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetPublicSiteInfo failed: %v", err)
|
t.Fatalf("GetPublicSiteInfo failed: %v", err)
|
||||||
@@ -674,6 +688,9 @@ func TestDistributionBrandingAndMarkup(t *testing.T) {
|
|||||||
if pubInfo.LogoURL != "https://cdn.example.com/logo.png" {
|
if pubInfo.LogoURL != "https://cdn.example.com/logo.png" {
|
||||||
t.Fatalf("expected LogoURL, got: %+v", pubInfo)
|
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"
|
faviconOnly := "https://cdn.example.com/favicon2.ico"
|
||||||
partial, err := repo.UpdateSiteBranding(t.Context(), 0, site.ID, UpdateSiteBrandingRequest{FaviconURL: &faviconOnly})
|
partial, err := repo.UpdateSiteBranding(t.Context(), 0, site.ID, UpdateSiteBrandingRequest{FaviconURL: &faviconOnly})
|
||||||
@@ -746,12 +763,24 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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{
|
ga := model.GameAccount{
|
||||||
OwnerID: user.ID,
|
OwnerID: user.ID,
|
||||||
GameName: "delta_force",
|
GameName: "delta_force",
|
||||||
ServerRegion: "cn",
|
ServerRegion: "cn",
|
||||||
LoginPlatform: "pc",
|
LoginPlatform: "pc",
|
||||||
Title: "满配三角洲账号",
|
Title: "满配三角洲账号",
|
||||||
|
HafCoinAmount: 100_000_000,
|
||||||
|
AssetSummary: datatypes.JSON(summary),
|
||||||
}
|
}
|
||||||
if err := db.Create(&ga).Error; err != nil {
|
if err := db.Create(&ga).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -786,6 +815,9 @@ func TestDistributionReceivedListingsAndWallet(t *testing.T) {
|
|||||||
if item.ListingNo != "RL-99001" || item.GameName != "delta_force" || item.OwnerNickname != "seller01" {
|
if item.ListingNo != "RL-99001" || item.GameName != "delta_force" || item.OwnerNickname != "seller01" {
|
||||||
t.Fatalf("unexpected received listing: %+v", item)
|
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) {
|
if err := repo.UpdateReceivedListingStatus(t.Context(), 0, listing.ID, "inactive"); !errors.Is(err, ErrInvalidListingStatus) {
|
||||||
t.Fatalf("inactive status err = %v", err)
|
t.Fatalf("inactive status err = %v", err)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func (h *Handler) Update(c *gin.Context) {
|
|||||||
response.BadRequest(c, listingBindErrorMessage(err))
|
response.BadRequest(c, listingBindErrorMessage(err))
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeListingError(c, err)
|
writeListingError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package listing
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest, siteCode ...string) (*ListingDTO, error) {
|
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 {
|
if !req.AgreedVirtualAssetSale || !req.AgreedSellerAgreement {
|
||||||
return nil, ErrAgreementRequired
|
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)
|
rules, err := s.publishRules(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -29,17 +38,21 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
effectiveSiteCode := ""
|
|
||||||
if len(siteCode) > 0 {
|
|
||||||
effectiveSiteCode = siteCode[0]
|
|
||||||
}
|
|
||||||
return s.repo.Create(ctx, ownerID, req, reviewRequired, cooldown, effectiveSiteCode)
|
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 {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
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)
|
rules, err := s.publishRules(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1008,3 +1008,139 @@ func TestListMineSiteIsolation(t *testing.T) {
|
|||||||
t.Fatalf("expected 2 listings, got %d", len(allItems))
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -617,6 +617,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
distributionProtectedRoutes.GET("/auth/me", distributionHandler.Me)
|
distributionProtectedRoutes.GET("/auth/me", distributionHandler.Me)
|
||||||
distributionProtectedRoutes.POST("/auth/logout", distributionHandler.Logout)
|
distributionProtectedRoutes.POST("/auth/logout", distributionHandler.Logout)
|
||||||
distributionProtectedRoutes.POST("/sites/:id/sync-accounts", distributionHandler.SyncSiteAccounts)
|
distributionProtectedRoutes.POST("/sites/:id/sync-accounts", distributionHandler.SyncSiteAccounts)
|
||||||
|
distributionProtectedRoutes.PUT("/sites/:id", distributionHandler.UpdateSite)
|
||||||
distributionProtectedRoutes.PUT("/sites/:id/branding", distributionHandler.UpdateSiteBranding)
|
distributionProtectedRoutes.PUT("/sites/:id/branding", distributionHandler.UpdateSiteBranding)
|
||||||
distributionProtectedRoutes.GET("/sites/:id/wallet", distributionHandler.GetSiteWallet)
|
distributionProtectedRoutes.GET("/sites/:id/wallet", distributionHandler.GetSiteWallet)
|
||||||
distributionProtectedRoutes.POST("/sites/:id/withdrawals", distributionHandler.ApplySiteWithdrawal)
|
distributionProtectedRoutes.POST("/sites/:id/withdrawals", distributionHandler.ApplySiteWithdrawal)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -63,6 +63,12 @@ export function formatMoney(cent: number | undefined | null): string {
|
|||||||
return `¥${((cent ?? 0) / 100).toFixed(2)}`
|
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 {
|
export interface CurrentMember {
|
||||||
admin_id: number
|
admin_id: number
|
||||||
username: string
|
username: string
|
||||||
@@ -121,6 +127,8 @@ export interface Site {
|
|||||||
banners_json?: string
|
banners_json?: string
|
||||||
announcements_json?: string
|
announcements_json?: string
|
||||||
footer_info_json?: string
|
footer_info_json?: string
|
||||||
|
listing_qrcode_url?: string
|
||||||
|
receive_ratio_delta?: number
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +338,11 @@ export async function listSites(): Promise<Site[]> {
|
|||||||
return extract(resp).items ?? []
|
return extract(resp).items ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateSite(siteID: number, payload: Partial<Pick<Site, 'receive_ratio_delta'>>): Promise<Site> {
|
||||||
|
const resp = await api.put<ApiResponse<Site>>(`/distribution/sites/${siteID}`, payload)
|
||||||
|
return extract(resp)
|
||||||
|
}
|
||||||
|
|
||||||
export interface DistributionSiteDefaults {
|
export interface DistributionSiteDefaults {
|
||||||
default_settlement_hold_days: number
|
default_settlement_hold_days: number
|
||||||
}
|
}
|
||||||
@@ -483,6 +496,9 @@ export interface ReceivedListing {
|
|||||||
login_platform: string
|
login_platform: string
|
||||||
rank_level: string
|
rank_level: string
|
||||||
haf_coin_amount: number
|
haf_coin_amount: number
|
||||||
|
seller_ratio?: number
|
||||||
|
seller_total_price_cent?: number
|
||||||
|
buyer_ratio?: number
|
||||||
price_cent: number
|
price_cent: number
|
||||||
deposit_amount_cent: number
|
deposit_amount_cent: number
|
||||||
status: string
|
status: string
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import {
|
import {
|
||||||
formatMoney,
|
formatMoney,
|
||||||
|
formatRatio,
|
||||||
listOrders,
|
listOrders,
|
||||||
listSites,
|
listSites,
|
||||||
fetchReceivedListings,
|
fetchReceivedListings,
|
||||||
@@ -527,6 +528,15 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{kind === 'receive' && (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="本站收号比例以主站为上限"
|
||||||
|
description="可在「品牌装修」页下调本站收号比例以扩大利润,但不能高于主站。比例越低,号主底价越高,本站提价利润空间越大。"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{kind === 'receive' ? (
|
{kind === 'receive' ? (
|
||||||
<Card>
|
<Card>
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -584,7 +594,7 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
|
|||||||
rowKey="listing_id"
|
rowKey="listing_id"
|
||||||
loading={recvLoading}
|
loading={recvLoading}
|
||||||
dataSource={receivedListings}
|
dataSource={receivedListings}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1400 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: recvPage,
|
current: recvPage,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -652,7 +662,21 @@ export default function Orders({ kind }: { kind: 'all' | 'sold' | 'receive' }) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '价格 / 押金',
|
title: '收号比例 / 收号价',
|
||||||
|
width: 160,
|
||||||
|
render: (_: unknown, row: ReceivedListing) => (
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong style={{ fontSize: 15, whiteSpace: 'nowrap', display: 'block' }}>
|
||||||
|
{formatRatio(row.seller_ratio)}
|
||||||
|
</Typography.Text>
|
||||||
|
<div style={{ fontSize: 12, color: '#888', whiteSpace: 'nowrap', marginTop: 2 }}>
|
||||||
|
收号价: {formatMoney(row.seller_total_price_cent)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '挂牌价 / 押金',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (_: unknown, row: ReceivedListing) => (
|
render: (_: unknown, row: ReceivedListing) => (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
InputNumber,
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
Divider,
|
Divider,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
listSites,
|
listSites,
|
||||||
updateSiteBranding,
|
updateSiteBranding,
|
||||||
|
updateSite,
|
||||||
getMe,
|
getMe,
|
||||||
type Site,
|
type Site,
|
||||||
type SiteBrandingPayload,
|
type SiteBrandingPayload,
|
||||||
@@ -71,6 +73,8 @@ export default function SiteSettings() {
|
|||||||
icp: '',
|
icp: '',
|
||||||
contact: '',
|
contact: '',
|
||||||
})
|
})
|
||||||
|
const [receiveRatioDelta, setReceiveRatioDelta] = useState(0)
|
||||||
|
const [savingRatio, setSavingRatio] = useState(false)
|
||||||
|
|
||||||
const loadSite = useCallback(async (id: number) => {
|
const loadSite = useCallback(async (id: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -100,6 +104,7 @@ export default function SiteSettings() {
|
|||||||
} catch {
|
} catch {
|
||||||
setFooterInfo({})
|
setFooterInfo({})
|
||||||
}
|
}
|
||||||
|
setReceiveRatioDelta(Math.max(0, found.receive_ratio_delta || 0))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
message.error('加载站点设置失败')
|
message.error('加载站点设置失败')
|
||||||
@@ -181,6 +186,22 @@ export default function SiteSettings() {
|
|||||||
setAnnouncements(announcements.filter((_, i) => i !== index))
|
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() {
|
async function handleSave() {
|
||||||
if (!siteID) return
|
if (!siteID) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -249,6 +270,35 @@ export default function SiteSettings() {
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card loading={loading}>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5} style={{ marginBottom: 4 }}>
|
||||||
|
本站收号比例
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
主站回收比例是上限。下调点数越大,号主底价越高、本站利润空间越大,但不能高于主站比例。0 表示与主站一致。
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Space wrap align="center">
|
||||||
|
<Typography.Text>相对主站下调</Typography.Text>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={20}
|
||||||
|
step={0.1}
|
||||||
|
precision={1}
|
||||||
|
value={receiveRatioDelta}
|
||||||
|
onChange={(value) => setReceiveRatioDelta(Math.max(0, Number(value || 0)))}
|
||||||
|
addonAfter="点"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} loading={savingRatio} onClick={() => void handleSaveReceiveRatio()}>
|
||||||
|
保存收号比例
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Row gutter={24}>
|
<Row gutter={24}>
|
||||||
{/* Left Visual Form Customizer */}
|
{/* Left Visual Form Customizer */}
|
||||||
<Col xs={24} lg={14}>
|
<Col xs={24} lg={14}>
|
||||||
|
|||||||
@@ -166,6 +166,16 @@ export default function Sites({ isSuperadmin = true }: { isSuperadmin?: boolean
|
|||||||
render: (_: unknown, row: Site) =>
|
render: (_: unknown, row: Site) =>
|
||||||
row.tax_enabled ? <Tag color="orange">{row.tax_rate_bps / 100}%</Tag> : <Tag>不征税</Tag>,
|
row.tax_enabled ? <Tag color="orange">{row.tax_rate_bps / 100}%</Tag> : <Tag>不征税</Tag>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '收号下调',
|
||||||
|
width: 120,
|
||||||
|
render: (_: unknown, row: Site) =>
|
||||||
|
Number(row.receive_ratio_delta || 0) > 0 ? (
|
||||||
|
<Tag color="gold">-{Number(row.receive_ratio_delta).toFixed(1)} 点</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag>与主站一致</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '创建时间',
|
title: '创建时间',
|
||||||
dataIndex: 'created_at',
|
dataIndex: 'created_at',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface DistributionSite {
|
|||||||
tax_rate_bps: number
|
tax_rate_bps: number
|
||||||
settlement_hold_days: number
|
settlement_hold_days: number
|
||||||
listing_qrcode_url?: string
|
listing_qrcode_url?: string
|
||||||
|
receive_ratio_delta?: number
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +153,7 @@ export async function updateDistributionSite(
|
|||||||
| 'tax_rate_bps'
|
| 'tax_rate_bps'
|
||||||
| 'settlement_hold_days'
|
| 'settlement_hold_days'
|
||||||
| 'listing_qrcode_url'
|
| 'listing_qrcode_url'
|
||||||
|
| 'receive_ratio_delta'
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Picture,
|
Picture,
|
||||||
Upload,
|
Upload,
|
||||||
|
Coin,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
fetchDistributionSites,
|
fetchDistributionSites,
|
||||||
@@ -45,6 +46,14 @@ const settlementForm = reactive({
|
|||||||
settlement_hold_days: defaultSettlementHoldDays.value,
|
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 qrDialogVisible = ref(false)
|
||||||
const qrSubmitting = ref(false)
|
const qrSubmitting = ref(false)
|
||||||
const qrUploading = 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) {
|
function handleOpenQrcode(row: DistributionSite) {
|
||||||
qrForm.site_id = row.id
|
qrForm.site_id = row.id
|
||||||
qrForm.site_name = row.site_name
|
qrForm.site_name = row.site_name
|
||||||
@@ -257,8 +289,8 @@ onMounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">分销站点管理</h2>
|
<h2 class="page-title">分销站点管理</h2>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
开通分销站、配置结算冷却期与发布群固定二维码。利润按 35% 收号 / 40% 卖出 / 25%
|
开通分销站、配置结算冷却期、收号比例上限与发布群固定二维码。利润按 35% 收号 / 40% 卖出 /
|
||||||
服务费切分。
|
25% 服务费切分。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
@@ -314,6 +346,17 @@ onMounted(() => {
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="收号下调" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="Number(row.receive_ratio_delta || 0) > 0 ? 'warning' : 'info'" size="small">
|
||||||
|
{{
|
||||||
|
Number(row.receive_ratio_delta || 0) > 0
|
||||||
|
? `-${Number(row.receive_ratio_delta).toFixed(1)} 点`
|
||||||
|
: '与主站一致'
|
||||||
|
}}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="发布群二维码" width="120" align="center">
|
<el-table-column label="发布群二维码" width="120" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<AuthImage
|
<AuthImage
|
||||||
@@ -331,7 +374,7 @@ onMounted(() => {
|
|||||||
{{ formatDateTime(row.created_at) }}
|
{{ formatDateTime(row.created_at) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="280" fixed="right">
|
<el-table-column label="操作" width="340" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -345,6 +388,9 @@ onMounted(() => {
|
|||||||
<el-button type="primary" link :icon="Clock" @click="handleOpenSettlement(row)">
|
<el-button type="primary" link :icon="Clock" @click="handleOpenSettlement(row)">
|
||||||
结算设置
|
结算设置
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button type="primary" link :icon="Coin" @click="handleOpenReceiveRatio(row)">
|
||||||
|
收号比例
|
||||||
|
</el-button>
|
||||||
<el-button type="primary" link :icon="Picture" @click="handleOpenQrcode(row)">
|
<el-button type="primary" link :icon="Picture" @click="handleOpenQrcode(row)">
|
||||||
二维码
|
二维码
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -522,6 +568,43 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 分站收号比例(主站为上限) -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="receiveRatioDialogVisible"
|
||||||
|
title="分站收号比例"
|
||||||
|
width="520px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
title="主站回收比例是上限。下调点数越大,号主底价越高、分站利润空间越大,但不能高于主站比例。0 表示与主站一致。"
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
/>
|
||||||
|
<el-form label-width="110px">
|
||||||
|
<el-form-item label="分销站点">
|
||||||
|
<el-input :model-value="receiveRatioForm.site_name" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="相对主站下调">
|
||||||
|
<el-input-number
|
||||||
|
v-model="receiveRatioForm.receive_ratio_delta"
|
||||||
|
:min="0"
|
||||||
|
:max="20"
|
||||||
|
:step="0.1"
|
||||||
|
:precision="1"
|
||||||
|
/>
|
||||||
|
<span class="settlement-hint">点(0-20)</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="receiveRatioDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="receiveRatioSubmitting" @click="handleReceiveRatioSubmit">
|
||||||
|
保存设置
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 发布群固定二维码 -->
|
<!-- 发布群固定二维码 -->
|
||||||
<el-dialog v-model="qrDialogVisible" title="发布群固定二维码" width="520px" destroy-on-close>
|
<el-dialog v-model="qrDialogVisible" title="发布群固定二维码" width="520px" destroy-on-close>
|
||||||
<el-alert
|
<el-alert
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
import type { PublishForm } from '@/shared/types/publish'
|
import type { PublishForm } from '@/shared/types/publish'
|
||||||
import { yuanToCent } from '@/shared/utils/money'
|
import { yuanToCent } from '@/shared/utils/money'
|
||||||
import { dailyLossOptions, formatNumber, roundRatio } from '@/shared/utils/pricing'
|
import { dailyLossOptions, formatNumber, roundRatio } from '@/shared/utils/pricing'
|
||||||
|
import { useDistributionSiteStore } from '@/stores/distributionSite'
|
||||||
|
|
||||||
const draftSaveDelay = 400
|
const draftSaveDelay = 400
|
||||||
const multiScreenshotLimit = 3
|
const multiScreenshotLimit = 3
|
||||||
@@ -54,6 +55,9 @@ interface UsePublishFormOptions {
|
|||||||
export function usePublishForm(options: UsePublishFormOptions) {
|
export function usePublishForm(options: UsePublishFormOptions) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const siteStore = useDistributionSiteStore()
|
||||||
|
const isDistribution = computed(() => siteStore.isDistribution)
|
||||||
|
const receiveRatioDelta = computed(() => Number(siteStore.receiveRatioDelta || 0))
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const suppressDraftSave = ref(false)
|
const suppressDraftSave = ref(false)
|
||||||
@@ -89,6 +93,8 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
quantityModes,
|
quantityModes,
|
||||||
screenshotFiles,
|
screenshotFiles,
|
||||||
selectedSkins,
|
selectedSkins,
|
||||||
|
isDistribution,
|
||||||
|
receiveRatioDelta,
|
||||||
})
|
})
|
||||||
|
|
||||||
// ========== 上号方式互斥 ==========
|
// ========== 上号方式互斥 ==========
|
||||||
@@ -911,6 +917,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...pricing,
|
...pricing,
|
||||||
|
isDistribution,
|
||||||
dailyLossOptions,
|
dailyLossOptions,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
router,
|
router,
|
||||||
|
|||||||
@@ -692,6 +692,14 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ratio-inherit-hint {
|
||||||
|
margin: 0;
|
||||||
|
color: #1677ff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.ratio-mode-grid {
|
.ratio-mode-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const {
|
|||||||
priceConfig,
|
priceConfig,
|
||||||
fireLevelPlaceholder,
|
fireLevelPlaceholder,
|
||||||
dailyLossMAmount,
|
dailyLossMAmount,
|
||||||
|
isDistribution,
|
||||||
calculatedDefaultSaleRatio,
|
calculatedDefaultSaleRatio,
|
||||||
maxAcceleratedSaleRatio,
|
maxAcceleratedSaleRatio,
|
||||||
calculatedCoinBasePrice,
|
calculatedCoinBasePrice,
|
||||||
@@ -694,11 +695,16 @@ const screenshotGuides: Record<string, string> = {
|
|||||||
</p>
|
</p>
|
||||||
<div class="ratio-panel">
|
<div class="ratio-panel">
|
||||||
<div class="ratio-reference">
|
<div class="ratio-reference">
|
||||||
<span>参考比例</span>
|
<span>{{ isDistribution ? '主站收号上限' : '参考比例' }}</span>
|
||||||
<strong>{{ calculatedDefaultSaleRatioText }}</strong>
|
<strong>{{ calculatedDefaultSaleRatioText }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<p class="ratio-range">{{ saleRatioRangeText }},比例越高出租速度越快,自行选择。</p>
|
<p v-if="isDistribution" class="ratio-inherit-hint">
|
||||||
<div class="ratio-mode-grid">
|
本站可下调收号比例换取更高利润,但不能高于主站比例。当前按站点配置计算。
|
||||||
|
</p>
|
||||||
|
<p class="ratio-range">
|
||||||
|
{{ saleRatioRangeText }}{{ isDistribution ? '。' : ',比例越高出租速度越快,自行选择。' }}
|
||||||
|
</p>
|
||||||
|
<div v-if="!isDistribution" class="ratio-mode-grid">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="ratio-mode-btn"
|
class="ratio-mode-btn"
|
||||||
|
|||||||
@@ -675,6 +675,12 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ratio-inherit-hint {
|
||||||
|
margin: 8px 0 0 !important;
|
||||||
|
color: #1677ff !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.ratio-reference {
|
.ratio-reference {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const {
|
|||||||
recommendedDepositAmount,
|
recommendedDepositAmount,
|
||||||
depositBreakdownItems,
|
depositBreakdownItems,
|
||||||
publishTitle,
|
publishTitle,
|
||||||
|
isDistribution,
|
||||||
hasAcceleratedSaleRatioInput,
|
hasAcceleratedSaleRatioInput,
|
||||||
isQuantityItemDisabled,
|
isQuantityItemDisabled,
|
||||||
handleSaveDraft,
|
handleSaveDraft,
|
||||||
@@ -557,11 +558,14 @@ function selectDailyLoss(value: string | number) {
|
|||||||
|
|
||||||
<div class="ratio-panel">
|
<div class="ratio-panel">
|
||||||
<div class="ratio-reference">
|
<div class="ratio-reference">
|
||||||
<span>参考比例</span>
|
<span>{{ isDistribution ? '主站收号上限' : '参考比例' }}</span>
|
||||||
<strong>{{ calculatedDefaultSaleRatioText }}</strong>
|
<strong>{{ calculatedDefaultSaleRatioText }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<p>{{ saleRatioRangeText }},比例越高出租速度越快。</p>
|
<p v-if="isDistribution" class="ratio-inherit-hint">
|
||||||
<div class="ratio-mode-grid">
|
本站可下调收号比例换取更高利润,但不能高于主站比例。当前按站点配置计算。
|
||||||
|
</p>
|
||||||
|
<p>{{ saleRatioRangeText }}{{ isDistribution ? '。' : ',比例越高出租速度越快。' }}</p>
|
||||||
|
<div v-if="!isDistribution" class="ratio-mode-grid">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="ratio-mode-btn"
|
class="ratio-mode-btn"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
hasAcceleratedSaleRatioInput as hasAcceleratedSaleRatioValue,
|
hasAcceleratedSaleRatioInput as hasAcceleratedSaleRatioValue,
|
||||||
isQuantityItemDisabledForInsurance,
|
isQuantityItemDisabledForInsurance,
|
||||||
readFinalSaleRatio,
|
readFinalSaleRatio,
|
||||||
|
applySiteReceiveRatioDelta,
|
||||||
roundMoney,
|
roundMoney,
|
||||||
roundRatio,
|
roundRatio,
|
||||||
} from '@/shared/utils/pricing'
|
} from '@/shared/utils/pricing'
|
||||||
@@ -31,6 +32,8 @@ export function usePricingCalculator(options: {
|
|||||||
quantityModes: Record<QuantityKey, ChargeMode>
|
quantityModes: Record<QuantityKey, ChargeMode>
|
||||||
screenshotFiles: Record<ScreenshotKey, string[]>
|
screenshotFiles: Record<ScreenshotKey, string[]>
|
||||||
selectedSkins: Ref<string[]>
|
selectedSkins: Ref<string[]>
|
||||||
|
isDistribution?: Ref<boolean>
|
||||||
|
receiveRatioDelta?: Ref<number>
|
||||||
}) {
|
}) {
|
||||||
const serverOptions = computed(() => options.publishOptions.value.server_options)
|
const serverOptions = computed(() => options.publishOptions.value.server_options)
|
||||||
const faceOptions = computed(() => options.publishOptions.value.face_options)
|
const faceOptions = computed(() => options.publishOptions.value.face_options)
|
||||||
@@ -85,13 +88,20 @@ export function usePricingCalculator(options: {
|
|||||||
const maxAcceleratedSaleRatio = computed(() =>
|
const maxAcceleratedSaleRatio = computed(() =>
|
||||||
calculatedDefaultSaleRatio.value > 0 ? roundRatio(calculatedDefaultSaleRatio.value + 10) : 0
|
calculatedDefaultSaleRatio.value > 0 ? roundRatio(calculatedDefaultSaleRatio.value + 10) : 0
|
||||||
)
|
)
|
||||||
const calculatedRatio = computed(() =>
|
const siteReceiveRatioDelta = computed(() =>
|
||||||
readFinalSaleRatio(
|
Math.max(0, Number(options.receiveRatioDelta?.value || 0))
|
||||||
|
)
|
||||||
|
const calculatedRatio = computed(() => {
|
||||||
|
if (calculatedDefaultSaleRatio.value <= 0) return 0
|
||||||
|
if (options.isDistribution?.value) {
|
||||||
|
return applySiteReceiveRatioDelta(calculatedDefaultSaleRatio.value, siteReceiveRatioDelta.value)
|
||||||
|
}
|
||||||
|
return readFinalSaleRatio(
|
||||||
calculatedDefaultSaleRatio.value,
|
calculatedDefaultSaleRatio.value,
|
||||||
options.form.accelerated_sale_ratio,
|
options.form.accelerated_sale_ratio,
|
||||||
maxAcceleratedSaleRatio.value
|
maxAcceleratedSaleRatio.value
|
||||||
)
|
)
|
||||||
)
|
})
|
||||||
const calculatedCoinBasePrice = computed(() => {
|
const calculatedCoinBasePrice = computed(() => {
|
||||||
if (calculatedRatio.value <= 0) return 0
|
if (calculatedRatio.value <= 0) return 0
|
||||||
return roundMoney(coinWanAmount.value / calculatedRatio.value)
|
return roundMoney(coinWanAmount.value / calculatedRatio.value)
|
||||||
@@ -131,6 +141,9 @@ export function usePricingCalculator(options: {
|
|||||||
)
|
)
|
||||||
const saleRatioRangeText = computed(() => {
|
const saleRatioRangeText = computed(() => {
|
||||||
if (calculatedDefaultSaleRatio.value <= 0) return '完成基础信息后自动计算参考比例'
|
if (calculatedDefaultSaleRatio.value <= 0) return '完成基础信息后自动计算参考比例'
|
||||||
|
if (options.isDistribution?.value) {
|
||||||
|
return `主站上限 1:${formatNumber(calculatedDefaultSaleRatio.value)},本站收号 1:${formatNumber(calculatedRatio.value)}`
|
||||||
|
}
|
||||||
return `可设置 1:${formatNumber(calculatedDefaultSaleRatio.value)} ~ 1:${formatNumber(maxAcceleratedSaleRatio.value)}`
|
return `可设置 1:${formatNumber(calculatedDefaultSaleRatio.value)} ~ 1:${formatNumber(maxAcceleratedSaleRatio.value)}`
|
||||||
})
|
})
|
||||||
const acceleratedSaleRatioPlaceholder = computed(() => {
|
const acceleratedSaleRatioPlaceholder = computed(() => {
|
||||||
@@ -195,6 +208,7 @@ export function usePricingCalculator(options: {
|
|||||||
calculatedSellerReferenceRatio,
|
calculatedSellerReferenceRatio,
|
||||||
calculatedDefaultSaleRatio,
|
calculatedDefaultSaleRatio,
|
||||||
maxAcceleratedSaleRatio,
|
maxAcceleratedSaleRatio,
|
||||||
|
siteReceiveRatioDelta,
|
||||||
calculatedRatio,
|
calculatedRatio,
|
||||||
calculatedCoinBasePrice,
|
calculatedCoinBasePrice,
|
||||||
calculatedConsumablePrice,
|
calculatedConsumablePrice,
|
||||||
|
|||||||
@@ -159,6 +159,12 @@ export function readFinalSaleRatio(
|
|||||||
return roundRatio(Math.min(Math.max(ratio, defaultRatio), maxAcceleratedSaleRatio))
|
return roundRatio(Math.min(Math.max(ratio, defaultRatio), maxAcceleratedSaleRatio))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applySiteReceiveRatioDelta(referenceRatio: number, delta: number) {
|
||||||
|
if (referenceRatio <= 0) return 0
|
||||||
|
const safeDelta = Math.min(20, Math.max(0, Number(delta) || 0))
|
||||||
|
return roundRatio(Math.max(referenceRatio - safeDelta, 0.01))
|
||||||
|
}
|
||||||
|
|
||||||
export function hasAcceleratedSaleRatioInput(value: number | '' | null | undefined) {
|
export function hasAcceleratedSaleRatioInput(value: number | '' | null | undefined) {
|
||||||
const ratio = Number(value)
|
const ratio = Number(value)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface DistributionSiteInfo {
|
|||||||
banners_json?: string
|
banners_json?: string
|
||||||
announcements_json?: string
|
announcements_json?: string
|
||||||
footer_info_json?: string
|
footer_info_json?: string
|
||||||
|
receive_ratio_delta?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +73,7 @@ export const useDistributionSiteStore = defineStore('distributionSite', {
|
|||||||
banners: [] as DistributionBanner[],
|
banners: [] as DistributionBanner[],
|
||||||
announcements: [] as string[],
|
announcements: [] as string[],
|
||||||
footerInfo: null as DistributionFooterInfo | null,
|
footerInfo: null as DistributionFooterInfo | null,
|
||||||
|
receiveRatioDelta: 0,
|
||||||
initialized: false,
|
initialized: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -175,6 +177,8 @@ export const useDistributionSiteStore = defineStore('distributionSite', {
|
|||||||
this.footerInfo = null
|
this.footerInfo = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.receiveRatioDelta = Math.max(0, Number(info.receive_ratio_delta || 0))
|
||||||
|
|
||||||
sessionStorage.setItem('hfb_site_code', this.siteCode)
|
sessionStorage.setItem('hfb_site_code', this.siteCode)
|
||||||
sessionStorage.setItem('hfb_site_name', this.siteName)
|
sessionStorage.setItem('hfb_site_name', this.siteName)
|
||||||
|
|
||||||
@@ -212,6 +216,7 @@ export const useDistributionSiteStore = defineStore('distributionSite', {
|
|||||||
this.banners = []
|
this.banners = []
|
||||||
this.announcements = []
|
this.announcements = []
|
||||||
this.footerInfo = null
|
this.footerInfo = null
|
||||||
|
this.receiveRatioDelta = 0
|
||||||
sessionStorage.removeItem('hfb_site_code')
|
sessionStorage.removeItem('hfb_site_code')
|
||||||
sessionStorage.removeItem('hfb_site_name')
|
sessionStorage.removeItem('hfb_site_name')
|
||||||
localStorage.removeItem('hfb_site_code')
|
localStorage.removeItem('hfb_site_code')
|
||||||
|
|||||||
Reference in New Issue
Block a user