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:data: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, } } 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:data: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, } } finally {} } module.exports = { buildDealSearchQuery, searchDeals, buildTitlePrefixQuery, buildTextSearchQuery, buildPrefixTextQuery, buildFuzzyTextQuery, }