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

348 lines
10 KiB
JavaScript

const dealDB = require("../../db/deal.db")
const userDB = require("../../db/user.db")
const dealAnalyticsDb = require("../../db/dealAnalytics.db")
const { getRedisClient } = require("./client")
const { mapDealToRedisJson } = require("./dealIndexing.service")
const { recordCacheMiss } = require("./cacheMetrics.service")
const {
getUserPublicFromRedis,
setUserPublicInRedis,
ensureUserMinTtl,
} = require("./userPublicCache.service")
const { reconcileDealCategoryIndex } = require("./categoryDealIndex.service")
const { reconcileDealSellerIndex } = require("./sellerDealIndex.service")
const DEAL_KEY_PREFIX = "deals:cache:"
const DEAL_ANALYTICS_TOTAL_PREFIX = "deals:analytics:total:"
const COMMENT_LOOKUP_KEY = "comments:lookup"
const COMMENT_IDS_KEY = "comments:ids"
function createRedisClient() {
return getRedisClient()
}
function toIso(value) {
return value instanceof Date ? value.toISOString() : value ?? null
}
function toEpochMs(value) {
if (value instanceof Date) return value.getTime()
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date.getTime()
}
async function getAnalyticsTotalsForDeal(dealId) {
const id = Number(dealId)
if (!Number.isInteger(id) || id <= 0) {
return { impressions: 0, views: 0, clicks: 0 }
}
await dealAnalyticsDb.ensureTotalsForDealIds([id])
const totals = await dealAnalyticsDb.getTotalsByDealIds([id])
const entry = totals?.[0] || { impressions: 0, views: 0, clicks: 0 }
return {
impressions: Number(entry.impressions) || 0,
views: Number(entry.views) || 0,
clicks: Number(entry.clicks) || 0,
}
}
async function cacheVotesAndAnalytics(redis, dealId, payload, { ttlSeconds, skipDbEnsure } = {}) {
const analyticsKey = `${DEAL_ANALYTICS_TOTAL_PREFIX}${dealId}`
const pipeline = redis.pipeline()
const totals = skipDbEnsure
? { impressions: 0, views: 0, clicks: 0 }
: await getAnalyticsTotalsForDeal(dealId)
pipeline.hset(
analyticsKey,
"impressions",
String(totals.impressions || 0),
"views",
String(totals.views || 0),
"clicks",
String(totals.clicks || 0)
)
if (ttlSeconds) {
pipeline.expire(analyticsKey, Number(ttlSeconds))
}
try {
await pipeline.exec()
} catch {
// ignore cache failures
}
}
async function ensureMinDealTtl(dealId, { minSeconds = 15 * 60 } = {}) {
const id = Number(dealId)
if (!Number.isInteger(id) || id <= 0) return { bumped: false }
const redis = createRedisClient()
const key = `${DEAL_KEY_PREFIX}${id}`
const analyticsKey = `${DEAL_ANALYTICS_TOTAL_PREFIX}${id}`
const minTtl = Math.max(1, Number(minSeconds) || 15 * 60)
try {
const ttl = await redis.ttl(key)
if (ttl === -2) return { bumped: false } // no key
if (ttl === -1 || ttl < minTtl) {
const nextTtl = minTtl
const pipeline = redis.pipeline()
pipeline.expire(key, nextTtl)
pipeline.expire(analyticsKey, nextTtl)
await pipeline.exec()
return { bumped: true, ttl: nextTtl }
}
return { bumped: false, ttl }
} catch {
return { bumped: false }
} finally {}
}
async function updateDealSavesInRedis({ dealId, userId, action, createdAt, minSeconds = 15 * 60 } = {}) {
const id = Number(dealId)
const uid = Number(userId)
if (!Number.isInteger(id) || id <= 0 || !Number.isInteger(uid) || uid <= 0) {
return { updated: false }
}
const normalized = String(action || "SAVE").toUpperCase()
if (!["SAVE", "UNSAVE"].includes(normalized)) return { updated: false }
const redis = createRedisClient()
const key = `${DEAL_KEY_PREFIX}${id}`
try {
await ensureMinDealTtl(id, { minSeconds })
return { updated: true }
} catch {
return { updated: false }
} finally {}
}
async function getDealFromRedis(dealId) {
const redis = createRedisClient()
try {
const key = `${DEAL_KEY_PREFIX}${dealId}`
const raw = await redis.call("JSON.GET", key)
if (!raw) {
await recordCacheMiss({ key })
return null
}
const deal = JSON.parse(raw)
if (!deal?.user && deal?.userId) {
const cachedUser = await getUserPublicFromRedis(deal.userId)
if (cachedUser) {
deal.user = cachedUser
} else {
const user = await userDB.findUser(
{ id: Number(deal.userId) },
{
select: {
id: true,
username: true,
avatarUrl: true,
userBadges: {
orderBy: { earnedAt: "desc" },
select: {
earnedAt: true,
badge: { select: { id: true, name: true, iconUrl: true, description: true } },
},
},
},
}
)
if (user) {
const ttl = await redis.ttl(key)
const ttlSeconds = ttl > 0 ? ttl : undefined
await setUserPublicInRedis(user, { ttlSeconds })
deal.user = user
}
}
}
return deal
} catch {
return null
} finally {}
}
async function cacheDealFromDb(dealId, { ttlSeconds = 1800 } = {}) {
const deal = await dealDB.findDeal(
{ id: Number(dealId) },
{
include: {
user: {
select: {
id: true,
username: true,
avatarUrl: true,
userBadges: {
orderBy: { earnedAt: "desc" },
select: {
earnedAt: true,
badge: { select: { id: true, name: true, iconUrl: true, description: true } },
},
},
},
},
images: { orderBy: { order: "asc" }, select: { id: true, imageUrl: true, order: true } },
dealTags: { include: { tag: { select: { id: true, slug: true, name: true } } } },
comments: {
orderBy: { createdAt: "desc" },
include: {
user: { select: { id: true, username: true, avatarUrl: true } },
likes: { select: { userId: true } },
},
},
aiReview: {
select: {
bestCategoryId: true,
tags: true,
needsReview: true,
hasIssue: true,
issueType: true,
issueReason: true,
},
},
},
}
)
if (!deal) return null
if (deal.user) {
await setUserPublicInRedis(deal.user, { ttlSeconds })
}
const payload = mapDealToRedisJson(deal)
const redis = createRedisClient()
try {
const key = `${DEAL_KEY_PREFIX}${deal.id}`
const pipeline = redis.pipeline()
pipeline.call("JSON.SET", key, "$", JSON.stringify(payload))
if (ttlSeconds) {
pipeline.expire(key, Number(ttlSeconds))
}
if (Array.isArray(payload.comments) && payload.comments.length) {
payload.comments.forEach((comment) => {
pipeline.hset(COMMENT_LOOKUP_KEY, String(comment.id), String(deal.id))
pipeline.sadd(COMMENT_IDS_KEY, String(comment.id))
})
}
await pipeline.exec()
await cacheVotesAndAnalytics(redis, deal.id, payload, { ttlSeconds })
await reconcileDealCategoryIndex({ before: null, after: payload })
await reconcileDealSellerIndex({ before: null, after: payload })
} catch {
// ignore cache failures
} finally {}
if (deal.user) {
await ensureUserMinTtl(deal.user.id, { minSeconds: ttlSeconds })
}
return deal.user ? { ...payload, user: deal.user } : payload
}
async function getDealIdByCommentId(commentId) {
const redis = createRedisClient()
try {
const raw = await redis.hget(COMMENT_LOOKUP_KEY, String(commentId))
if (!raw) {
await recordCacheMiss({ key: `${COMMENT_LOOKUP_KEY}:${commentId}`, label: "comment-lookup" })
}
return raw ? Number(raw) : null
} catch {
return null
} finally {}
}
async function getOrCacheDeal(dealId, { ttlSeconds = 1800 } = {}) {
const cached = await getDealFromRedis(dealId)
if (cached) {
await ensureMinDealTtl(dealId, { minSeconds: ttlSeconds })
return cached
}
return cacheDealFromDb(dealId, { ttlSeconds })
}
async function getOrCacheDealForModeration(dealId, { ttlSeconds = 1800 } = {}) {
const cached = await getDealFromRedis(dealId)
if (cached) return { deal: cached, fromCache: true }
const deal = await cacheDealFromDb(dealId, { ttlSeconds })
return { deal, fromCache: false }
}
async function updateDealInRedis(dealId, patch = {}, { updatedAt = new Date() } = {}) {
const redis = createRedisClient()
const key = `${DEAL_KEY_PREFIX}${dealId}`
const ts = toEpochMs(updatedAt)
const iso = toIso(updatedAt)
try {
const beforeRaw = await redis.call("JSON.GET", key)
if (!beforeRaw) return null
let beforeDeal = null
try {
beforeDeal = JSON.parse(beforeRaw)
} catch {}
const pipeline = redis.pipeline()
Object.entries(patch || {}).forEach(([field, value]) => {
if (value === undefined) return
pipeline.call("JSON.SET", key, `$.${field}`, JSON.stringify(value))
})
if (iso) pipeline.call("JSON.SET", key, "$.updatedAt", JSON.stringify(iso))
if (ts != null) pipeline.call("JSON.SET", key, "$.updatedAtTs", JSON.stringify(ts))
await pipeline.exec()
const raw = await redis.call("JSON.GET", key)
const updated = raw ? JSON.parse(raw) : null
await reconcileDealCategoryIndex({ before: beforeDeal, after: updated })
await reconcileDealSellerIndex({ before: beforeDeal, after: updated })
return updated
} catch {
return null
} finally {}
}
async function setDealInRedis(
dealId,
payload,
{ ttlSeconds = 31 * 24 * 60 * 60, skipAnalyticsInit = false } = {}
) {
if (!dealId || !payload) return null
const redis = createRedisClient()
const key = `${DEAL_KEY_PREFIX}${dealId}`
try {
const pipeline = redis.pipeline()
pipeline.call("JSON.SET", key, "$", JSON.stringify(payload))
if (ttlSeconds) {
pipeline.expire(key, Number(ttlSeconds))
}
if (Array.isArray(payload.comments) && payload.comments.length) {
payload.comments.forEach((comment) => {
pipeline.hset(COMMENT_LOOKUP_KEY, String(comment.id), String(dealId))
pipeline.sadd(COMMENT_IDS_KEY, String(comment.id))
})
}
await pipeline.exec()
await cacheVotesAndAnalytics(redis, dealId, payload, {
ttlSeconds,
skipDbEnsure: skipAnalyticsInit,
})
await reconcileDealCategoryIndex({ before: null, after: payload })
await reconcileDealSellerIndex({ before: null, after: payload })
return payload
} catch {
return payload
} finally {}
}
module.exports = {
getDealFromRedis,
cacheDealFromDb,
getOrCacheDeal,
getDealIdByCommentId,
getOrCacheDealForModeration,
updateDealInRedis,
setDealInRedis,
ensureMinDealTtl,
updateDealSavesInRedis,
}