refactor: drop unused local search catalog

This commit is contained in:
zetaloop
2026-05-03 05:46:24 +08:00
parent 0e7270aa8d
commit be329865b3
2 changed files with 0 additions and 606 deletions
-189
View File
@@ -1,189 +0,0 @@
import type { SearchResponse, SearchResultItem, SearchSort } from "@/lib/search/types"
import type { Player, PlayerService, Shop } from "@/lib/types"
export interface SearchCatalogParams {
q?: string
selectedGames?: string[]
min?: string
max?: string
onlyOnline?: boolean
minRating?: string
sort?: SearchSort
limit?: number
offset?: number
}
export interface SearchCatalogData {
players: Player[]
shops: Shop[]
services: PlayerService[]
}
type InternalResultItem = SearchResultItem & { __index: number }
function minPriceFromServices(services: PlayerService[]) {
return services.length === 0 ? 0 : Math.min(...services.map((s) => s.price))
}
function unitFromCheapestService(services: PlayerService[]): PlayerService["unit"] {
if (services.length === 0) return "局"
return services.reduce((prev, curr) => (prev.price < curr.price ? prev : curr)).unit
}
export function searchCatalog(
params: SearchCatalogParams,
data: SearchCatalogData,
): SearchResponse {
const q = params.q ?? ""
const query = q ? q.toLowerCase() : ""
const selectedGames = params.selectedGames ?? []
const min = params.min ?? ""
const max = params.max ?? ""
const minP = min ? Number(min) : 0
const maxP = max ? Number(max) : Infinity
const onlyOnline = params.onlyOnline ?? false
const minRating = Number(params.minRating ?? "0")
const sort = params.sort ?? "composite"
const offset = Math.max(0, params.offset ?? 0)
const limit = Math.max(0, params.limit ?? 12)
const shopDerivedById = new Map<
string,
{
minPrice: number
unit: PlayerService["unit"]
games: string[]
hasAvailable: boolean
}
>()
for (const shop of data.shops) {
const shopPlayers = data.players.filter((player) => player.shopId === shop.id)
const playerIds = new Set(shopPlayers.map((player) => player.id))
const shopServices = data.services.filter((service) => playerIds.has(service.playerId))
const shopMinPrice = shopServices.length > 0 ? Math.min(...shopServices.map((s) => s.price)) : 0
const shopUnit = shopServices.length > 0 ? unitFromCheapestService(shopServices) : "局"
const shopGames = [...new Set(shopServices.map((service) => service.gameName))]
const hasAvailable = shopPlayers.some((player) => player.status === "available")
shopDerivedById.set(shop.id, {
minPrice: shopMinPrice,
unit: shopUnit,
games: shopGames,
hasAvailable,
})
}
let nextIndex = 0
const results: InternalResultItem[] = []
for (const player of data.players) {
results.push({
__index: nextIndex++,
type: "player",
player,
rating: player.rating,
orders: player.totalOrders,
minPrice: minPriceFromServices(player.services),
unit: unitFromCheapestService(player.services),
})
}
for (const shop of data.shops) {
const derived = shopDerivedById.get(shop.id)
if (!derived) continue
results.push({
__index: nextIndex++,
type: "shop",
shop,
rating: Number(shop.rating),
orders: shop.totalOrders,
minPrice: derived.minPrice,
unit: derived.unit,
games: derived.games,
hasAvailable: derived.hasAvailable,
})
}
const filtered = results.filter((item) => {
if (query) {
if (item.type === "player") {
const matchName = item.player.user.nickname.toLowerCase().includes(query)
const matchTags = item.player.tags.some((tag) => tag.toLowerCase().includes(query))
const matchGames = item.player.games.some((game) => game.toLowerCase().includes(query))
if (!matchName && !matchTags && !matchGames) return false
} else {
const matchName = item.shop.name.toLowerCase().includes(query)
const matchDescription = item.shop.description.toLowerCase().includes(query)
const matchGames = item.games.some((game) => game.toLowerCase().includes(query))
if (!matchName && !matchDescription && !matchGames) return false
}
}
if (selectedGames.length > 0) {
if (item.type === "player") {
const hasGame = item.player.games.some((game) => selectedGames.includes(game))
if (!hasGame) return false
} else {
const hasGame = item.games.some((game) => selectedGames.includes(game))
if (!hasGame) return false
}
}
if (item.minPrice < minP) return false
if (max && item.minPrice > maxP) return false
if (onlyOnline) {
if (item.type === "player") {
if (item.player.status !== "available") return false
} else {
if (!item.hasAvailable) return false
}
}
if (item.type === "player") {
if (item.player.rating < minRating) return false
} else {
if (Number(item.shop.rating) < minRating) return false
}
return true
})
filtered.sort((a, b) => {
let diff = 0
switch (sort) {
case "rating":
diff = b.rating - a.rating
break
case "price_asc":
diff = a.minPrice - b.minPrice
break
case "price_desc":
diff = b.minPrice - a.minPrice
break
case "orders":
diff = b.orders - a.orders
break
case "composite": {
const scoreA = a.rating * Math.log10(a.orders + 1)
const scoreB = b.rating * Math.log10(b.orders + 1)
diff = scoreB - scoreA
break
}
default:
diff = 0
}
if (diff !== 0) return diff
return a.__index - b.__index
})
const total = filtered.length
const items = filtered.slice(offset, offset + limit).map(({ __index, ...item }) => item)
return { items, meta: { total, offset, limit } }
}
-417
View File
@@ -1,417 +0,0 @@
import type { SearchCatalogData } from "@/lib/search/search-catalog"
import { searchCatalog } from "@/lib/search/search-catalog"
import { describe, expect, it } from "vitest"
const createdAt = "2025-01-01T00:00:00.000Z"
const players: SearchCatalogData["players"] = [
{
id: "1006",
user: {
id: "2006",
username: "u1006",
nickname: "Winter",
avatar: "/avatars/u1006.png",
role: "player",
createdAt,
},
rating: 4.8,
totalOrders: 120,
completionRate: 0.98,
status: "available",
games: ["王者荣耀"],
services: [
{
id: "s-1006-wzry",
playerId: "1006",
gameId: "g-wzry",
gameName: "王者荣耀",
title: "王者荣耀陪练",
description: "",
price: 30,
unit: "局",
availability: ["weekends"],
},
],
shopId: "3002",
gender: true,
tags: ["moba"],
},
{
id: "1007",
user: {
id: "2007",
username: "u1007",
nickname: "u7",
avatar: "/avatars/u1007.png",
role: "player",
createdAt,
},
rating: 4.0,
totalOrders: 20,
completionRate: 0.9,
status: "busy",
games: ["英雄联盟"],
services: [
{
id: "s-1007-lol",
playerId: "1007",
gameId: "g-lol",
gameName: "英雄联盟",
title: "英雄联盟陪练",
description: "",
price: 18,
unit: "局",
availability: ["weekdays"],
},
],
shopId: "3003",
gender: true,
tags: [],
},
{
id: "1008",
user: {
id: "2008",
username: "u1008",
nickname: "Ace",
avatar: "/avatars/u1008.png",
role: "player",
createdAt,
},
rating: 4.7,
totalOrders: 80,
completionRate: 0.97,
status: "available",
games: ["CS2"],
services: [
{
id: "s-1008-cs2",
playerId: "1008",
gameId: "g-cs2",
gameName: "CS2",
title: "CS2代练",
description: "",
price: 10,
unit: "局",
availability: ["weekends"],
},
],
shopId: "3001",
gender: false,
tags: ["fps"],
},
{
id: "1009",
user: {
id: "2009",
username: "u1009",
nickname: "u9",
avatar: "/avatars/u1009.png",
role: "player",
createdAt,
},
rating: 3.5,
totalOrders: 5,
completionRate: 0.85,
status: "offline",
games: ["英雄联盟"],
services: [
{
id: "s-1009-lol",
playerId: "1009",
gameId: "g-lol",
gameName: "英雄联盟",
title: "英雄联盟陪练",
description: "",
price: 12,
unit: "局",
availability: ["weekends"],
},
],
shopId: "3003",
gender: true,
tags: [],
},
]
const shops: SearchCatalogData["shops"] = [
{
id: "3001",
owner: {
id: "4001",
username: "owner3001",
nickname: "Owner 3001",
avatar: "/avatars/owner3001.png",
role: "owner",
createdAt,
},
name: "CS2 Hub",
description: "",
rating: "4.6",
totalOrders: 300,
playerCount: 1,
commissionType: "fixed",
commissionValue: "0",
allowMultiShop: false,
allowIndependentOrders: false,
dispatchMode: "manual",
announcements: [],
templateConfig: { sections: [] },
},
{
id: "3002",
owner: {
id: "4002",
username: "owner3002",
nickname: "Owner 3002",
avatar: "/avatars/owner3002.png",
role: "owner",
createdAt,
},
name: "Yuki Studio",
description: "",
rating: "4.2",
totalOrders: 50,
playerCount: 1,
commissionType: "fixed",
commissionValue: "0",
allowMultiShop: false,
allowIndependentOrders: false,
dispatchMode: "manual",
announcements: [],
templateConfig: { sections: [] },
},
{
id: "3003",
owner: {
id: "4003",
username: "owner3003",
nickname: "Owner 3003",
avatar: "/avatars/owner3003.png",
role: "owner",
createdAt,
},
name: "Quiet Shop",
description: "",
rating: "3.8",
totalOrders: 10,
playerCount: 2,
commissionType: "fixed",
commissionValue: "0",
allowMultiShop: false,
allowIndependentOrders: false,
dispatchMode: "manual",
announcements: [],
templateConfig: { sections: [] },
},
]
const services: SearchCatalogData["services"] = players.flatMap((p) => p.services)
const data: SearchCatalogData = {
players,
shops,
services,
}
describe("searchCatalog", () => {
describe("q matching (case-insensitive)", () => {
it("matches player nickname", () => {
const res = searchCatalog({ q: "winter", limit: 50 }, data)
const playerItems = res.items.filter((i) => i.type === "player")
expect(playerItems.some((i) => i.type === "player" && i.player.id === "1006")).toBe(true)
})
it("matches player nickname case-insensitively", () => {
const res = searchCatalog({ q: "WINTER", limit: 50 }, data)
const playerItems = res.items.filter((i) => i.type === "player")
expect(playerItems.some((i) => i.type === "player" && i.player.id === "1006")).toBe(true)
})
it("matches shop name", () => {
const res = searchCatalog({ q: "yuki", limit: 50 }, data)
const shopItems = res.items.filter((i) => i.type === "shop")
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3002")).toBe(true)
})
it("matches player game name", () => {
const res = searchCatalog({ q: "cs2", limit: 50 }, data)
const playerItems = res.items.filter((i) => i.type === "player")
// 1008 has CS2 in games
expect(playerItems.some((i) => i.type === "player" && i.player.id === "1008")).toBe(true)
})
it("matches shop derived games", () => {
const res = searchCatalog({ q: "cs2", limit: 50 }, data)
const shopItems = res.items.filter((i) => i.type === "shop")
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3001")).toBe(true)
})
it("returns all items when q is empty", () => {
const res = searchCatalog({ limit: 50 }, data)
expect(res.meta.total).toBe(players.length + shops.length)
})
})
describe("selectedGames ANY-match", () => {
it("filters players by game", () => {
const res = searchCatalog({ selectedGames: ["CS2"], limit: 50 }, data)
const playerItems = res.items.filter((i) => i.type === "player")
expect(playerItems.every((i) => i.type === "player" && i.player.games.includes("CS2"))).toBe(
true,
)
expect(playerItems.length).toBeGreaterThan(0)
})
it("filters shops by derived games from services", () => {
const res = searchCatalog({ selectedGames: ["CS2"], limit: 50 }, data)
const shopItems = res.items.filter((i) => i.type === "shop")
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3001")).toBe(true)
})
it("uses ANY-match (OR) for multiple games", () => {
const res = searchCatalog({ selectedGames: ["CS2", "王者荣耀"], limit: 50 }, data)
// Should include players with either game
expect(res.meta.total).toBeGreaterThan(0)
const playerItems = res.items.filter((i) => i.type === "player")
for (const item of playerItems) {
if (item.type === "player") {
expect(item.player.games.includes("CS2") || item.player.games.includes("王者荣耀")).toBe(
true,
)
}
}
})
})
describe("onlyOnline semantics", () => {
it("filters players by status === available", () => {
const res = searchCatalog({ onlyOnline: true, limit: 50 }, data)
const playerItems = res.items.filter((i) => i.type === "player")
for (const item of playerItems) {
if (item.type === "player") {
expect(item.player.status).toBe("available")
}
}
// u7 is busy, u9 is offline — they should be excluded
expect(playerItems.some((i) => i.type === "player" && i.player.id === "1007")).toBe(false)
expect(playerItems.some((i) => i.type === "player" && i.player.id === "1009")).toBe(false)
})
it("filters shops by hasAvailable (any player available)", () => {
const res = searchCatalog({ onlyOnline: true, limit: 50 }, data)
const shopItems = res.items.filter((i) => i.type === "shop")
for (const item of shopItems) {
if (item.type === "shop") {
expect(item.hasAvailable).toBe(true)
}
}
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3003")).toBe(false)
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3001")).toBe(true)
})
})
describe("price min/max parsing", () => {
it("filters by min price", () => {
const res = searchCatalog({ min: "25", limit: 50 }, data)
for (const item of res.items) {
expect(item.minPrice).toBeGreaterThanOrEqual(25)
}
})
it("filters by max price", () => {
const res = searchCatalog({ max: "20", limit: 50 }, data)
for (const item of res.items) {
expect(item.minPrice).toBeLessThanOrEqual(20)
}
})
it("NaN min does not filter (min='abc' → minP=NaN)", () => {
const resAll = searchCatalog({ limit: 50 }, data)
const resNaN = searchCatalog({ min: "abc", limit: 50 }, data)
// NaN comparison: item.minPrice < NaN is always false, so nothing is excluded by min
// But items with minPrice < 0 would pass too — effectively no min filter
expect(resNaN.meta.total).toBe(resAll.meta.total)
})
it("NaN max does not filter (max='abc' → maxP=NaN)", () => {
const resAll = searchCatalog({ limit: 50 }, data)
const resNaN = searchCatalog({ max: "abc", limit: 50 }, data)
// max is truthy ("abc") so the max filter runs, but item.minPrice > NaN is always false
expect(resNaN.meta.total).toBe(resAll.meta.total)
})
})
describe("sort options", () => {
it("sorts by rating descending", () => {
const res = searchCatalog({ sort: "rating", limit: 50 }, data)
for (let i = 1; i < res.items.length; i++) {
expect(res.items[i - 1].rating).toBeGreaterThanOrEqual(res.items[i].rating)
}
})
it("sorts by orders descending", () => {
const res = searchCatalog({ sort: "orders", limit: 50 }, data)
for (let i = 1; i < res.items.length; i++) {
expect(res.items[i - 1].orders).toBeGreaterThanOrEqual(res.items[i].orders)
}
})
it("sorts by price ascending", () => {
const res = searchCatalog({ sort: "price_asc", limit: 50 }, data)
for (let i = 1; i < res.items.length; i++) {
expect(res.items[i - 1].minPrice).toBeLessThanOrEqual(res.items[i].minPrice)
}
})
it("sorts by price descending", () => {
const res = searchCatalog({ sort: "price_desc", limit: 50 }, data)
for (let i = 1; i < res.items.length; i++) {
expect(res.items[i - 1].minPrice).toBeGreaterThanOrEqual(res.items[i].minPrice)
}
})
it("sorts by composite formula descending", () => {
const res = searchCatalog({ sort: "composite", limit: 50 }, data)
const scores = res.items.map((i) => i.rating * Math.log10(i.orders + 1))
for (let i = 1; i < scores.length; i++) {
expect(scores[i - 1]).toBeGreaterThanOrEqual(scores[i])
}
})
it("uses stable tie-breaker by insertion order", () => {
// Create data with identical ratings to test tie-breaking
const res = searchCatalog({ sort: "rating", limit: 50 }, data)
// Items with same rating should preserve insertion order (players before shops)
const sameRating = res.items.filter((i) => i.rating === res.items[0].rating)
if (sameRating.length > 1) {
// They should be in original insertion order
expect(sameRating.length).toBeGreaterThan(0)
}
})
})
describe("pagination offset/limit", () => {
it("returns correct meta", () => {
const res = searchCatalog({ limit: 2, offset: 0 }, data)
expect(res.meta.limit).toBe(2)
expect(res.meta.offset).toBe(0)
expect(res.items.length).toBe(2)
expect(res.meta.total).toBe(players.length + shops.length)
})
it("offset skips items", () => {
const all = searchCatalog({ limit: 50 }, data)
const page2 = searchCatalog({ limit: 2, offset: 2 }, data)
expect(page2.items[0]).toEqual(all.items[2])
expect(page2.items[1]).toEqual(all.items[3])
})
it("offset beyond total returns empty items", () => {
const res = searchCatalog({ limit: 10, offset: 100 }, data)
expect(res.items.length).toBe(0)
expect(res.meta.total).toBeGreaterThan(0)
})
})
})