diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go
index 2b460b1..a89ba67 100644
--- a/backend/internal/modules/listing/dto.go
+++ b/backend/internal/modules/listing/dto.go
@@ -104,6 +104,8 @@ type PublicListQuery struct {
SkinName []string
MinCoin *float64
MaxCoin *float64
+ MinRatio *float64
+ MaxRatio *float64
MinPrice *float64
MaxPrice *float64
MinDeposit *float64
diff --git a/backend/internal/modules/listing/handler_parse.go b/backend/internal/modules/listing/handler_parse.go
index 853baf3..f006378 100644
--- a/backend/internal/modules/listing/handler_parse.go
+++ b/backend/internal/modules/listing/handler_parse.go
@@ -94,6 +94,8 @@ func parsePublicListQuery(c *gin.Context) PublicListQuery {
SkinName: parseCSVQuery(firstNonEmpty(c.Query("skin_name"), c.Query("skin"))),
MinCoin: parseOptionalFloat(c.Query("min_coin")),
MaxCoin: parseOptionalFloat(c.Query("max_coin")),
+ MinRatio: parseOptionalFloat(c.Query("min_ratio")),
+ MaxRatio: parseOptionalFloat(c.Query("max_ratio")),
MinPrice: parseOptionalFloat(c.Query("min_price")),
MaxPrice: parseOptionalFloat(c.Query("max_price")),
MinDeposit: parseOptionalFloat(c.Query("min_deposit")),
diff --git a/backend/internal/modules/listing/public_filter.go b/backend/internal/modules/listing/public_filter.go
index 96e3db6..9b3648f 100644
--- a/backend/internal/modules/listing/public_filter.go
+++ b/backend/internal/modules/listing/public_filter.go
@@ -65,6 +65,9 @@ func matchesPublicQuery(item ListingDTO, query PublicListQuery) bool {
if !numberInRange(coinM, NumberRange{Min: query.MinCoin, Max: query.MaxCoin}) {
return false
}
+ if !numberInRange(ratioFromListing(item), NumberRange{Min: query.MinRatio, Max: query.MaxRatio}) {
+ return false
+ }
if !numberInRange(price, NumberRange{Min: query.MinPrice, Max: query.MaxPrice}) {
return false
}
@@ -106,10 +109,12 @@ func sortPublicListings(items []ListingDTO, sortKey string) {
return aAmmo > bAmmo
}
return a.HafCoinAmount > b.HafCoinAmount
- case "published", "recommended", "comprehensive", "":
+ case "published":
return publicRecentLess(a, b)
+ case "recommended", "comprehensive", "":
+ return publicShuffleLess(a, b)
default:
- return publicRecentLess(a, b)
+ return publicShuffleLess(a, b)
}
})
}
@@ -129,6 +134,19 @@ func publicRecentLess(a ListingDTO, b ListingDTO) bool {
return a.ID > b.ID
}
+func publicShuffleLess(a ListingDTO, b ListingDTO) bool {
+ aKey := publicShuffleKey(a.ID)
+ bKey := publicShuffleKey(b.ID)
+ if aKey != bKey {
+ return aKey < bKey
+ }
+ return a.ID < b.ID
+}
+
+func publicShuffleKey(id uint64) uint64 {
+ return (id*1103515245 + uint64(publicShuffleSeed())) % 2147483647
+}
+
func matchesPublicZone(item ListingDTO, zone string) bool {
switch zone {
case "", "all":
@@ -225,6 +243,22 @@ func coinMFromListing(item ListingDTO) float64 {
return float64(item.HafCoinAmount) / 1000000
}
+func coinWanFromListing(item ListingDTO) float64 {
+ return float64(item.HafCoinAmount) / 10000
+}
+
+func ratioFromListing(item ListingDTO) float64 {
+ ratio := readSummaryNumber(item.AssetSummary["publish_ratio"])
+ if ratio > 0 {
+ return ratio
+ }
+ price := float64(item.PriceCent) / 100
+ if price <= 0 {
+ return 0
+ }
+ return coinWanFromListing(item) / price
+}
+
func readAssetString(summary map[string]any, key string) string {
if summary == nil {
return ""
diff --git a/backend/internal/modules/listing/public_filter_test.go b/backend/internal/modules/listing/public_filter_test.go
new file mode 100644
index 0000000..f0c341d
--- /dev/null
+++ b/backend/internal/modules/listing/public_filter_test.go
@@ -0,0 +1,46 @@
+package listing
+
+import "testing"
+
+func TestFilterPublicListingsByRatio(t *testing.T) {
+ minRatio := 20.0
+ maxRatio := 30.0
+ items := []ListingDTO{
+ {ID: 1, HafCoinAmount: 100_000_000, PriceCent: 10_000, AssetSummary: map[string]any{"publish_ratio": 10.0}},
+ {ID: 2, HafCoinAmount: 100_000_000, PriceCent: 4_000, AssetSummary: map[string]any{"publish_ratio": 25.0}},
+ {ID: 3, HafCoinAmount: 100_000_000, PriceCent: 2_500, AssetSummary: map[string]any{"publish_ratio": 40.0}},
+ }
+
+ filtered := filterPublicListings(items, PublicListQuery{
+ MinRatio: &minRatio,
+ MaxRatio: &maxRatio,
+ })
+
+ if len(filtered) != 1 || filtered[0].ID != 2 {
+ t.Fatalf("expected only listing 2 in ratio range, got %#v", filtered)
+ }
+}
+
+func TestSortPublicListingsDefaultUsesShuffleKey(t *testing.T) {
+ items := []ListingDTO{
+ {ID: 101},
+ {ID: 102},
+ {ID: 103},
+ {ID: 104},
+ }
+
+ sortPublicListings(items, "recommended")
+
+ for index := 1; index < len(items); index++ {
+ previous := publicShuffleKey(items[index-1].ID)
+ current := publicShuffleKey(items[index].ID)
+ if previous > current {
+ t.Fatalf("expected shuffled order by key, got ids=%v", []uint64{
+ items[0].ID,
+ items[1].ID,
+ items[2].ID,
+ items[3].ID,
+ })
+ }
+ }
+}
diff --git a/backend/internal/modules/listing/public_query.go b/backend/internal/modules/listing/public_query.go
index 5ed02d3..9741786 100644
--- a/backend/internal/modules/listing/public_query.go
+++ b/backend/internal/modules/listing/public_query.go
@@ -2,6 +2,7 @@ package listing
import (
"context"
+ "fmt"
"strings"
"time"
@@ -117,7 +118,10 @@ func canListPublicWithSQL(query PublicListQuery) bool {
if len(query.SkinGroup) > 0 || len(query.SkinName) > 0 || len(query.ResourceRanges) > 0 {
return false
}
- if query.MinCoin != nil || query.MaxCoin != nil || query.MinPrice != nil || query.MaxPrice != nil {
+ if query.MinCoin != nil || query.MaxCoin != nil || query.MinRatio != nil || query.MaxRatio != nil {
+ return false
+ }
+ if query.MinPrice != nil || query.MaxPrice != nil {
return false
}
if query.MinDeposit != nil || query.MaxDeposit != nil || query.MinTotal != nil || query.MaxTotal != nil {
@@ -142,11 +146,18 @@ func applyPublicSQLSort(db *gorm.DB, sortKey string) *gorm.DB {
return db.Order("l.price_cent DESC, l.published_at DESC, l.id DESC")
case "coinDesc":
return db.Order("a.haf_coin_amount DESC, l.published_at DESC, l.id DESC")
- default:
+ case "published":
return db.Order("l.published_at DESC, l.id DESC")
+ default:
+ return db.Order(fmt.Sprintf("((l.id * 1103515245 + %d) %% 2147483647) ASC, l.id ASC", publicShuffleSeed()))
}
}
+func publicShuffleSeed() int64 {
+ now := time.Now().UTC()
+ return int64(now.Year()*366 + now.YearDay())
+}
+
func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int64, error) {
now := time.Now()
r.publicZoneCountsMu.Lock()
diff --git a/frontend/src/features/listings/api/listings.ts b/frontend/src/features/listings/api/listings.ts
index 1784a91..d00793c 100644
--- a/frontend/src/features/listings/api/listings.ts
+++ b/frontend/src/features/listings/api/listings.ts
@@ -64,6 +64,8 @@ export interface PublicListingQuery {
skin_name?: string
min_coin?: number
max_coin?: number
+ min_ratio?: number
+ max_ratio?: number
min_price?: number
max_price?: number
min_deposit?: number
diff --git a/frontend/src/features/listings/components/HomeFilters.vue b/frontend/src/features/listings/components/HomeFilters.vue
index 0b8b090..31570d8 100644
--- a/frontend/src/features/listings/components/HomeFilters.vue
+++ b/frontend/src/features/listings/components/HomeFilters.vue
@@ -6,6 +6,7 @@ import StringFilter from './StringFilter.vue'
import SkinFilter from './SkinFilter.vue'
import {
coinRangeOptions,
+ ratioRangeOptions,
moneyRangeOptions,
totalRangeOptions,
fireLevelRangeOptions,
@@ -134,6 +135,17 @@ function closePopover() {
@close="closePopover"
/>
+
+
{
skin_name: filters.skinName,
min_coin: filters.minCoin,
max_coin: filters.maxCoin,
+ min_ratio: filters.minRatio,
+ max_ratio: filters.maxRatio,
min_price: filters.minPrice,
max_price: filters.maxPrice,
min_deposit: filters.minDeposit,
diff --git a/frontend/src/features/listings/views/MobileHomeView.vue b/frontend/src/features/listings/views/MobileHomeView.vue
index 7e83ae5..d8608a3 100644
--- a/frontend/src/features/listings/views/MobileHomeView.vue
+++ b/frontend/src/features/listings/views/MobileHomeView.vue
@@ -111,6 +111,12 @@ const rangePresets: Record(() => [
minPlaceholder: '最低',
maxPlaceholder: '最高',
},
+ {
+ key: 'ratio',
+ title: '比例',
+ type: 'range',
+ unit: 'w/元',
+ minPlaceholder: '最低比例',
+ maxPlaceholder: '最高比例',
+ },
{ key: 'server', title: '区服', type: 'chips', options: serverFilterOptions.value },
{ key: 'region', title: '资产地区', type: 'chips', options: regionFilterOptions.value },
{ key: 'login', title: '上号方式', type: 'chips', options: loginMethodFilterOptions.value },
@@ -436,6 +450,9 @@ function buildListingQuery(page: number): PublicListingQuery {
} else if (key === 'coin') {
query.min_coin = min
query.max_coin = max
+ } else if (key === 'ratio') {
+ query.min_ratio = min
+ query.max_ratio = max
} else if (key === 'secretKd') {
query.min_secret_kd = min
query.max_secret_kd = max