282 lines
7.5 KiB
JavaScript
282 lines
7.5 KiB
JavaScript
const { getRedisClient } = require("./client")
|
|
|
|
function createRedisClient() {
|
|
return getRedisClient()
|
|
}
|
|
|
|
function normalizeIds(ids = []) {
|
|
return Array.from(
|
|
new Set(
|
|
(Array.isArray(ids) ? ids : [])
|
|
.map((id) => Number(id))
|
|
.filter((id) => Number.isInteger(id) && id > 0)
|
|
)
|
|
)
|
|
}
|
|
|
|
function buildTagFilter(field, values = []) {
|
|
const list = (Array.isArray(values) ? values : [])
|
|
.map((v) => String(v).trim())
|
|
.filter(Boolean)
|
|
if (!list.length) return null
|
|
return `@${field}:{${list.join("|")}}`
|
|
}
|
|
|
|
function buildSaleTypeQuery(values = []) {
|
|
const list = (Array.isArray(values) ? values : [])
|
|
.map((v) => String(v).trim().toUpperCase())
|
|
.filter(Boolean)
|
|
if (!list.length) return null
|
|
|
|
const hasCode = list.includes("CODE")
|
|
const others = list.filter((v) => v !== "CODE")
|
|
|
|
if (!hasCode) {
|
|
return `@saletype:{${list.join("|")}}`
|
|
}
|
|
|
|
const codePart = "(@saletype:{CODE} @hasCouponCode:[1 1])"
|
|
if (!others.length) return codePart
|
|
const otherPart = `@saletype:{${others.join("|")}}`
|
|
return `(${codePart} | ${otherPart})`
|
|
}
|
|
|
|
function buildNumericRange(field, min, max) {
|
|
if (min == null && max == null) return null
|
|
const lower = min == null ? "-inf" : String(min)
|
|
const upper = max == null ? "+inf" : String(max)
|
|
return `@${field}:[${lower} ${upper}]`
|
|
}
|
|
|
|
function buildNumericOrList(field, ids = []) {
|
|
const list = normalizeIds(ids)
|
|
if (!list.length) return null
|
|
if (list.length === 1) return `@${field}:[${list[0]} ${list[0]}]`
|
|
if (list.length <= 6) {
|
|
return `(${list.map((id) => `@${field}:[${id} ${id}]`).join("|")})`
|
|
}
|
|
|
|
// compress into contiguous ranges to shorten query
|
|
const sorted = [...list].sort((a, b) => a - b)
|
|
const ranges = []
|
|
let start = sorted[0]
|
|
let prev = sorted[0]
|
|
for (let i = 1; i < sorted.length; i += 1) {
|
|
const current = sorted[i]
|
|
if (current === prev + 1) {
|
|
prev = current
|
|
continue
|
|
}
|
|
ranges.push([start, prev])
|
|
start = current
|
|
prev = current
|
|
}
|
|
ranges.push([start, prev])
|
|
|
|
if (ranges.length === 1) {
|
|
return `@${field}:[${ranges[0][0]} ${ranges[0][1]}]`
|
|
}
|
|
return `(${ranges.map((r) => `@${field}:[${r[0]} ${r[1]}]`).join("|")})`
|
|
}
|
|
|
|
function buildDealSearchQuery({
|
|
statuses,
|
|
categoryIds,
|
|
sellerIds,
|
|
saleTypes,
|
|
minPrice,
|
|
maxPrice,
|
|
minScore,
|
|
maxScore,
|
|
} = {}) {
|
|
const parts = []
|
|
const statusFilter = buildTagFilter("status", statuses)
|
|
if (statusFilter) parts.push(statusFilter)
|
|
|
|
const saleTypeFilter = buildSaleTypeQuery(saleTypes)
|
|
if (saleTypeFilter) parts.push(saleTypeFilter)
|
|
|
|
const categoryFilter = buildNumericOrList("categoryId", categoryIds)
|
|
if (categoryFilter) parts.push(categoryFilter)
|
|
|
|
const sellerFilter = buildNumericOrList("sellerId", sellerIds)
|
|
if (sellerFilter) parts.push(sellerFilter)
|
|
|
|
const priceFilter = buildNumericRange("price", minPrice, maxPrice)
|
|
if (priceFilter) parts.push(priceFilter)
|
|
|
|
const scoreFilter = buildNumericRange("score", minScore, maxScore)
|
|
if (scoreFilter) parts.push(scoreFilter)
|
|
|
|
return parts.length ? parts.join(" ") : "*"
|
|
}
|
|
|
|
function escapeRedisSearchText(input = "") {
|
|
return String(input)
|
|
.replace(/\\/g, "\\\\")
|
|
.replace(/["'@\\-]/g, "\\$&")
|
|
.replace(/[{}()[\]|<>~*?:]/g, "\\$&")
|
|
}
|
|
|
|
function buildTextSearchQuery(term) {
|
|
const trimmed = String(term || "").trim()
|
|
if (!trimmed) return null
|
|
const tokens = trimmed.split(/\s+/).filter(Boolean).map(escapeRedisSearchText)
|
|
if (!tokens.length) return null
|
|
const query = tokens.join(" ")
|
|
return `(@title:(${query}) | @description:(${query}))`
|
|
}
|
|
|
|
function buildPrefixTextQuery(term) {
|
|
const trimmed = String(term || "").trim()
|
|
if (!trimmed) return null
|
|
const tokens = trimmed
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.map(escapeRedisSearchText)
|
|
.map((token) => `${token}*`)
|
|
if (!tokens.length) return null
|
|
const query = tokens.join(" ")
|
|
return `(@title:(${query}) | @description:(${query}))`
|
|
}
|
|
|
|
function buildFuzzyTextQuery(term) {
|
|
const trimmed = String(term || "").trim()
|
|
if (!trimmed) return null
|
|
const tokens = trimmed
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.map(escapeRedisSearchText)
|
|
.map((token) => `%${token}%`)
|
|
if (!tokens.length) return null
|
|
const query = tokens.join(" ")
|
|
return `(@title:(${query}) | @description:(${query}))`
|
|
}
|
|
|
|
function buildTitlePrefixQuery(term) {
|
|
const trimmed = String(term || "").trim()
|
|
if (!trimmed) return null
|
|
const tokens = trimmed.split(/\s+/).filter(Boolean).map(escapeRedisSearchText)
|
|
if (!tokens.length) return null
|
|
const titleQuery = tokens.map((t) => `${t}*`).join(" ")
|
|
return `@status:{ACTIVE} @title:(${titleQuery})`
|
|
}
|
|
|
|
function resolveSort({ sortBy, sortDir } = {}) {
|
|
const field = String(sortBy || "createdAtTs").toLowerCase()
|
|
const dir = String(sortDir || "desc").toUpperCase() === "ASC" ? "ASC" : "DESC"
|
|
if (field === "score") return { field: "score", dir }
|
|
if (field === "price") return { field: "price", dir }
|
|
if (field === "createdat" || field === "createdatts") return { field: "createdAtTs", dir }
|
|
return { field: "createdAtTs", dir }
|
|
}
|
|
|
|
async function aggregatePriceRange(query) {
|
|
const redis = createRedisClient()
|
|
try {
|
|
const results = await redis.call(
|
|
"FT.AGGREGATE",
|
|
"idx:deals",
|
|
query || "*",
|
|
"GROUPBY",
|
|
"0",
|
|
"REDUCE",
|
|
"MIN",
|
|
"1",
|
|
"@price",
|
|
"AS",
|
|
"minPrice",
|
|
"REDUCE",
|
|
"MAX",
|
|
"1",
|
|
"@price",
|
|
"AS",
|
|
"maxPrice",
|
|
"DIALECT",
|
|
"3"
|
|
)
|
|
|
|
if (!Array.isArray(results) || results.length < 2) {
|
|
return { minPrice: null, maxPrice: null }
|
|
}
|
|
|
|
const row = results[1]
|
|
if (!Array.isArray(row)) return { minPrice: null, maxPrice: null }
|
|
const data = {}
|
|
for (let i = 0; i < row.length; i += 2) {
|
|
data[row[i]] = row[i + 1]
|
|
}
|
|
const min = data.minPrice != null ? Number(data.minPrice) : null
|
|
const max = data.maxPrice != null ? Number(data.maxPrice) : null
|
|
return {
|
|
minPrice: Number.isFinite(min) ? min : null,
|
|
maxPrice: Number.isFinite(max) ? max : null,
|
|
}
|
|
} catch {
|
|
return { minPrice: null, maxPrice: null }
|
|
} finally {}
|
|
}
|
|
|
|
async function searchDeals({
|
|
query,
|
|
page = 1,
|
|
limit = 20,
|
|
sortBy = "createdAtTs",
|
|
sortDir = "DESC",
|
|
includeMinMax = false,
|
|
} = {}) {
|
|
const normalizedPage = Math.max(1, Number(page) || 1)
|
|
const normalizedLimit = Math.max(1, Math.min(Number(limit) || 20, 50))
|
|
const offset = (normalizedPage - 1) * normalizedLimit
|
|
const sort = resolveSort({ sortBy, sortDir })
|
|
|
|
const redis = createRedisClient()
|
|
try {
|
|
const range = includeMinMax ? await aggregatePriceRange(query) : { minPrice: null, maxPrice: null }
|
|
const results = await redis.call(
|
|
"FT.SEARCH",
|
|
"idx:deals",
|
|
query || "*",
|
|
"SORTBY",
|
|
sort.field,
|
|
sort.dir,
|
|
"LIMIT",
|
|
String(offset),
|
|
String(normalizedLimit),
|
|
"RETURN",
|
|
"0",
|
|
"DIALECT",
|
|
"3"
|
|
)
|
|
|
|
const total = Number(results?.[0] || 0)
|
|
const ids = Array.isArray(results) ? results.slice(1) : []
|
|
const dealIds = ids
|
|
.map((key) => {
|
|
const parts = String(key).split(":")
|
|
return Number(parts[2])
|
|
})
|
|
.filter((id) => Number.isInteger(id) && id > 0)
|
|
|
|
return {
|
|
total,
|
|
page: normalizedPage,
|
|
totalPages: Math.ceil(total / normalizedLimit),
|
|
dealIds,
|
|
minPrice: range.minPrice,
|
|
maxPrice: range.maxPrice,
|
|
}
|
|
} catch {
|
|
return null
|
|
} finally {}
|
|
}
|
|
|
|
module.exports = {
|
|
buildDealSearchQuery,
|
|
searchDeals,
|
|
buildTitlePrefixQuery,
|
|
buildTextSearchQuery,
|
|
buildPrefixTextQuery,
|
|
buildFuzzyTextQuery,
|
|
}
|