328 lines
10 KiB
JavaScript
328 lines
10 KiB
JavaScript
const { getRedisClient } = require("./redis/client")
|
|
const userInterestProfileDb = require("../db/userInterestProfile.db")
|
|
const { getCategoryDealIndexKey } = require("./redis/categoryDealIndex.service")
|
|
const { getDealsByIdsFromRedis } = require("./redis/hotDealList.service")
|
|
const { getNewDealIds } = require("./redis/newDealList.service")
|
|
|
|
const FEED_KEY_PREFIX = "deals:lists:personalized:user:"
|
|
const FEED_TTL_SECONDS = Math.max(60, Number(process.env.PERSONAL_FEED_TTL_SECONDS) || 2 * 60 * 60)
|
|
const FEED_REBUILD_THRESHOLD_SECONDS = Math.max(
|
|
60,
|
|
Number(process.env.PERSONAL_FEED_REBUILD_THRESHOLD_SECONDS) || 60 * 60
|
|
)
|
|
const FEED_CANDIDATE_LIMIT = Math.max(20, Number(process.env.PERSONAL_FEED_CANDIDATE_LIMIT) || 120)
|
|
const FEED_MAX_CATEGORIES = Math.max(1, Number(process.env.PERSONAL_FEED_MAX_CATEGORIES) || 8)
|
|
const FEED_PER_CATEGORY_LIMIT = Math.max(5, Number(process.env.PERSONAL_FEED_PER_CATEGORY_LIMIT) || 40)
|
|
const FEED_LOOKBACK_DAYS = Math.max(1, Number(process.env.PERSONAL_FEED_LOOKBACK_DAYS) || 30)
|
|
const FEED_NOISE_MAX = Math.max(0, Number(process.env.PERSONAL_FEED_NOISE_MAX) || 50)
|
|
const FEED_PAGE_LIMIT = 20
|
|
|
|
function normalizePositiveInt(value) {
|
|
const num = Number(value)
|
|
if (!Number.isInteger(num) || num <= 0) return null
|
|
return num
|
|
}
|
|
|
|
function normalizePagination({ page, limit }) {
|
|
const rawPage = Number(page)
|
|
const rawLimit = Number(limit)
|
|
const safePage = Number.isInteger(rawPage) && rawPage > 0 ? rawPage : 1
|
|
const safeLimit =
|
|
Number.isInteger(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 50) : 20
|
|
return { page: safePage, limit: safeLimit, skip: (safePage - 1) * safeLimit }
|
|
}
|
|
|
|
function getLatestKey(userId) {
|
|
return `${FEED_KEY_PREFIX}${userId}:latest`
|
|
}
|
|
|
|
function getFeedKey(userId, feedId) {
|
|
return `${FEED_KEY_PREFIX}${userId}:${feedId}`
|
|
}
|
|
|
|
function getFeedKeyMatchPattern(userId) {
|
|
return `${FEED_KEY_PREFIX}${userId}:*`
|
|
}
|
|
|
|
function parseCategoryScores(rawScores) {
|
|
if (!rawScores || typeof rawScores !== "object" || Array.isArray(rawScores)) return []
|
|
const entries = []
|
|
for (const [categoryIdRaw, scoreRaw] of Object.entries(rawScores)) {
|
|
const categoryId = normalizePositiveInt(categoryIdRaw)
|
|
const score = Number(scoreRaw)
|
|
if (!categoryId || !Number.isFinite(score) || score <= 0) continue
|
|
entries.push({ categoryId, score })
|
|
}
|
|
return entries.sort((a, b) => b.score - a.score)
|
|
}
|
|
|
|
function buildFallbackFeedIds(dealIds = []) {
|
|
return Array.from(
|
|
new Set(
|
|
(Array.isArray(dealIds) ? dealIds : [])
|
|
.map((id) => Number(id))
|
|
.filter((id) => Number.isInteger(id) && id > 0)
|
|
)
|
|
).slice(0, FEED_CANDIDATE_LIMIT)
|
|
}
|
|
|
|
function computePersonalScore({ categoryScore, dealScore }) {
|
|
const safeCategory = Math.max(0, Number(categoryScore) || 0)
|
|
const safeDealScore = Math.max(1, Number(dealScore) || 0)
|
|
const noise = FEED_NOISE_MAX > 0 ? Math.floor(Math.random() * (FEED_NOISE_MAX + 1)) : 0
|
|
return safeCategory * safeDealScore + noise
|
|
}
|
|
|
|
async function getFeedFromRedis(redis, userId) {
|
|
const latestId = await redis.get(getLatestKey(userId))
|
|
if (!latestId) return null
|
|
const key = getFeedKey(userId, latestId)
|
|
const raw = await redis.call("JSON.GET", key)
|
|
const ttl = Number(await redis.ttl(key))
|
|
if (!raw || ttl <= 0) return null
|
|
try {
|
|
const parsed = JSON.parse(raw)
|
|
return {
|
|
id: String(parsed.id || latestId),
|
|
dealIds: buildFallbackFeedIds(parsed.dealIds || []),
|
|
ttl,
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function listUserFeedKeys(redis, userId) {
|
|
const pattern = getFeedKeyMatchPattern(userId)
|
|
const keys = []
|
|
let cursor = "0"
|
|
|
|
do {
|
|
const [nextCursor, batch] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100)
|
|
cursor = String(nextCursor)
|
|
if (Array.isArray(batch) && batch.length) {
|
|
batch.forEach((key) => {
|
|
if (String(key).endsWith(":latest")) return
|
|
keys.push(String(key))
|
|
})
|
|
}
|
|
} while (cursor !== "0")
|
|
|
|
return Array.from(new Set(keys))
|
|
}
|
|
|
|
function extractFeedIdFromKey(userId, key) {
|
|
const prefix = `${FEED_KEY_PREFIX}${userId}:`
|
|
if (!String(key).startsWith(prefix)) return null
|
|
const feedId = String(key).slice(prefix.length)
|
|
return feedId || null
|
|
}
|
|
|
|
async function getBestFeedFromRedis(redis, userId) {
|
|
const keys = await listUserFeedKeys(redis, userId)
|
|
if (!keys.length) return null
|
|
|
|
const pipeline = redis.pipeline()
|
|
keys.forEach((key) => pipeline.ttl(key))
|
|
keys.forEach((key) => pipeline.call("JSON.GET", key))
|
|
const results = await pipeline.exec()
|
|
if (!Array.isArray(results) || !results.length) return null
|
|
|
|
const ttlResults = results.slice(0, keys.length)
|
|
const jsonResults = results.slice(keys.length)
|
|
|
|
let best = null
|
|
keys.forEach((key, idx) => {
|
|
try {
|
|
const ttl = Number(ttlResults[idx]?.[1] ?? -1)
|
|
if (!Number.isFinite(ttl) || ttl <= 0) return
|
|
const raw = jsonResults[idx]?.[1]
|
|
if (!raw) return
|
|
const parsed = JSON.parse(raw)
|
|
const dealIds = buildFallbackFeedIds(parsed?.dealIds || [])
|
|
const feedId = extractFeedIdFromKey(userId, key) || String(parsed?.id || "")
|
|
if (!feedId) return
|
|
|
|
if (!best || ttl > best.ttl) {
|
|
best = {
|
|
id: feedId,
|
|
dealIds,
|
|
ttl,
|
|
}
|
|
}
|
|
} catch {}
|
|
})
|
|
|
|
return best
|
|
}
|
|
|
|
async function setLatestPointer(redis, userId, feedId, ttlSeconds) {
|
|
const ttl = Math.max(1, Number(ttlSeconds) || FEED_TTL_SECONDS)
|
|
await redis.set(getLatestKey(userId), String(feedId), "EX", ttl)
|
|
}
|
|
|
|
async function collectCandidateIdsFromIndexes(redis, topCategories = []) {
|
|
if (!topCategories.length) return new Map()
|
|
const cutoffTs = Date.now() - FEED_LOOKBACK_DAYS * 24 * 60 * 60 * 1000
|
|
const pipeline = redis.pipeline()
|
|
const refs = []
|
|
|
|
topCategories.forEach((entry) => {
|
|
const key = getCategoryDealIndexKey(entry.categoryId)
|
|
if (!key) return
|
|
pipeline.zrevrangebyscore(key, "+inf", String(cutoffTs), "LIMIT", 0, FEED_PER_CATEGORY_LIMIT)
|
|
refs.push(entry)
|
|
})
|
|
|
|
if (!refs.length) return new Map()
|
|
const results = await pipeline.exec()
|
|
const categoryByDealId = new Map()
|
|
|
|
results.forEach((result, idx) => {
|
|
const [, rawIds] = result || []
|
|
const categoryEntry = refs[idx]
|
|
const ids = Array.isArray(rawIds) ? rawIds : []
|
|
ids.forEach((id) => {
|
|
const dealId = Number(id)
|
|
if (!Number.isInteger(dealId) || dealId <= 0) return
|
|
if (!categoryByDealId.has(dealId)) {
|
|
categoryByDealId.set(dealId, categoryEntry)
|
|
}
|
|
})
|
|
})
|
|
|
|
return categoryByDealId
|
|
}
|
|
|
|
async function buildPersonalizedFeedForUser(redis, userId) {
|
|
const profile = await userInterestProfileDb.getUserInterestProfile(userId)
|
|
const categories = parseCategoryScores(profile?.categoryScores).slice(0, FEED_MAX_CATEGORIES)
|
|
|
|
if (!categories.length) {
|
|
const fallback = await getNewDealIds({})
|
|
return {
|
|
id: String(Date.now()),
|
|
dealIds: buildFallbackFeedIds(fallback?.dealIds || []),
|
|
}
|
|
}
|
|
|
|
const categoryByDealId = await collectCandidateIdsFromIndexes(redis, categories)
|
|
const candidateIds = Array.from(categoryByDealId.keys()).slice(0, FEED_CANDIDATE_LIMIT * 3)
|
|
|
|
if (!candidateIds.length) {
|
|
const fallback = await getNewDealIds({})
|
|
return {
|
|
id: String(Date.now()),
|
|
dealIds: buildFallbackFeedIds(fallback?.dealIds || []),
|
|
}
|
|
}
|
|
|
|
const deals = await getDealsByIdsFromRedis(candidateIds, userId)
|
|
const scored = deals
|
|
.filter((deal) => String(deal?.status || "").toUpperCase() === "ACTIVE")
|
|
.map((deal) => {
|
|
const entry = categoryByDealId.get(Number(deal.id))
|
|
const categoryScore = Number(entry?.score || 0)
|
|
return {
|
|
id: Number(deal.id),
|
|
score: computePersonalScore({
|
|
categoryScore,
|
|
dealScore: Number(deal.score || 0),
|
|
}),
|
|
}
|
|
})
|
|
.filter((item) => Number.isInteger(item.id) && item.id > 0)
|
|
|
|
scored.sort((a, b) => b.score - a.score)
|
|
|
|
const rankedIds = Array.from(new Set(scored.map((item) => item.id))).slice(0, FEED_CANDIDATE_LIMIT)
|
|
if (!rankedIds.length) {
|
|
const fallback = await getNewDealIds({})
|
|
return {
|
|
id: String(Date.now()),
|
|
dealIds: buildFallbackFeedIds(fallback?.dealIds || []),
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: String(Date.now()),
|
|
dealIds: rankedIds,
|
|
}
|
|
}
|
|
|
|
async function cacheFeed(redis, userId, feed) {
|
|
const feedId = String(feed?.id || Date.now())
|
|
const dealIds = buildFallbackFeedIds(feed?.dealIds || [])
|
|
const key = getFeedKey(userId, feedId)
|
|
const payload = {
|
|
id: feedId,
|
|
createdAt: new Date().toISOString(),
|
|
total: dealIds.length,
|
|
dealIds,
|
|
}
|
|
await redis.call("JSON.SET", key, "$", JSON.stringify(payload))
|
|
await redis.expire(key, FEED_TTL_SECONDS)
|
|
await setLatestPointer(redis, userId, feedId, FEED_TTL_SECONDS)
|
|
return { id: feedId, dealIds, ttl: FEED_TTL_SECONDS }
|
|
}
|
|
|
|
async function getOrBuildFeedIds(userId) {
|
|
const uid = normalizePositiveInt(userId)
|
|
if (!uid) return { id: null, dealIds: [] }
|
|
const redis = getRedisClient()
|
|
|
|
try {
|
|
const best = (await getBestFeedFromRedis(redis, uid)) || (await getFeedFromRedis(redis, uid))
|
|
if (best && best.ttl >= FEED_REBUILD_THRESHOLD_SECONDS) {
|
|
await setLatestPointer(redis, uid, best.id, best.ttl)
|
|
return best
|
|
}
|
|
if (best && best.ttl > 0) {
|
|
// Keep current feed as fallback while creating a fresh one.
|
|
const built = await buildPersonalizedFeedForUser(redis, uid)
|
|
const cached = await cacheFeed(redis, uid, built)
|
|
return cached?.dealIds?.length ? cached : best
|
|
}
|
|
} catch {}
|
|
|
|
try {
|
|
const built = await buildPersonalizedFeedForUser(redis, uid)
|
|
return cacheFeed(redis, uid, built)
|
|
} catch {
|
|
const fallback = await getNewDealIds({})
|
|
const dealIds = buildFallbackFeedIds(fallback?.dealIds || [])
|
|
const payload = { id: String(Date.now()), dealIds, ttl: 0 }
|
|
try {
|
|
return cacheFeed(redis, uid, payload)
|
|
} catch {
|
|
return payload
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getPersonalizedDeals({
|
|
userId,
|
|
page = 1,
|
|
} = {}) {
|
|
const uid = normalizePositiveInt(userId)
|
|
if (!uid) return { page: 1, total: 0, totalPages: 0, results: [], personalizedListId: null }
|
|
|
|
const pagination = normalizePagination({ page, limit: FEED_PAGE_LIMIT })
|
|
const feed = await getOrBuildFeedIds(uid)
|
|
const ids = Array.isArray(feed?.dealIds) ? feed.dealIds : []
|
|
const pageIds = ids.slice(pagination.skip, pagination.skip + pagination.limit)
|
|
const deals = await getDealsByIdsFromRedis(pageIds, uid)
|
|
|
|
return {
|
|
page: pagination.page,
|
|
total: ids.length,
|
|
totalPages: ids.length ? Math.ceil(ids.length / pagination.limit) : 0,
|
|
results: deals,
|
|
personalizedListId: feed?.id || null,
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
getPersonalizedDeals,
|
|
}
|