HotTRDealsBackend/services/redis/dealSearch.service.js
2026-02-07 22:42:02 +00:00

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,
}