HotTRDealsBackend/services/personalizedFeed.service.js
2026-02-09 21:47:55 +00:00

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