const { getRedisClient } = require("./redis/client") const userInterestProfileDb = require("../db/userInterestProfile.db") const USER_INTEREST_INCREMENT_HASH_KEY = "bull:dbsync:userInterestIncrements" const USER_INTEREST_INCREMENT_HASH_KEY_PREFIX = `${USER_INTEREST_INCREMENT_HASH_KEY}:` const DAILY_USER_KEY_PREFIX = "users:interest:daily:" const USER_INTEREST_ACTIONS = Object.freeze({ CATEGORY_VISIT: "CATEGORY_VISIT", DEAL_VIEW: "DEAL_VIEW", DEAL_CLICK: "DEAL_CLICK", DEAL_HOT_VOTE: "DEAL_HOT_VOTE", DEAL_SAVE: "DEAL_SAVE", COMMENT_CREATE: "COMMENT_CREATE", }) const ACTION_POINTS = Object.freeze({ [USER_INTEREST_ACTIONS.CATEGORY_VISIT]: 1, [USER_INTEREST_ACTIONS.DEAL_VIEW]: 2, [USER_INTEREST_ACTIONS.DEAL_CLICK]: 12, [USER_INTEREST_ACTIONS.DEAL_HOT_VOTE]: 5, [USER_INTEREST_ACTIONS.DEAL_SAVE]: 8, [USER_INTEREST_ACTIONS.COMMENT_CREATE]: 4, }) const DEFAULT_DAILY_CAP = Number(process.env.USER_INTEREST_DAILY_CATEGORY_CAP) || 50 const DEFAULT_TTL_SECONDS = Number(process.env.USER_INTEREST_DAILY_TTL_SECONDS) || 24 * 60 * 60 const DEFAULT_FULL_LIMIT = Number(process.env.USER_INTEREST_ACTION_FULL_LIMIT) || 5 const DEFAULT_HALF_LIMIT = Number(process.env.USER_INTEREST_ACTION_HALF_LIMIT) || 10 const DEFAULT_SATURATION_RATIO = Number(process.env.USER_INTEREST_SATURATION_RATIO) || 0.3 const DEFAULT_INCREMENT_SHARDS = Math.max( 1, Math.min(128, Number(process.env.USER_INTEREST_INCREMENT_SHARDS) || 32) ) const APPLY_CAPS_SCRIPT = ` local actionCount = redis.call("HINCRBY", KEYS[1], ARGV[1], 1) local basePoints = tonumber(ARGV[3]) or 0 local fullLimit = tonumber(ARGV[4]) or 5 local halfLimit = tonumber(ARGV[5]) or 10 local ttlSeconds = tonumber(ARGV[6]) or 86400 local dailyCap = tonumber(ARGV[7]) or 50 local awarded = 0 if actionCount <= fullLimit then awarded = basePoints elseif actionCount <= halfLimit then awarded = math.floor(basePoints / 2) else awarded = 0 end if awarded <= 0 then local ttlNow = redis.call("TTL", KEYS[1]) if ttlNow < 0 then redis.call("EXPIRE", KEYS[1], ttlSeconds) end return {0, actionCount} end local usedToday = tonumber(redis.call("HGET", KEYS[1], ARGV[2]) or "0") local remaining = dailyCap - usedToday if remaining <= 0 then local ttlNow = redis.call("TTL", KEYS[1]) if ttlNow < 0 then redis.call("EXPIRE", KEYS[1], ttlSeconds) end return {0, actionCount} end if awarded > remaining then awarded = remaining end if awarded > 0 then redis.call("HINCRBY", KEYS[1], ARGV[2], awarded) end local ttlNow = redis.call("TTL", KEYS[1]) if ttlNow < 0 then redis.call("EXPIRE", KEYS[1], ttlSeconds) end return {awarded, actionCount} ` function normalizePositiveInt(value) { const num = Number(value) if (!Number.isInteger(num) || num <= 0) return null return num } function normalizeAction(action) { const normalized = String(action || "").trim().toUpperCase() if (!ACTION_POINTS[normalized]) return null return normalized } function buildDailyUserKey(userId) { return `${DAILY_USER_KEY_PREFIX}${userId}` } function buildDailyCategoryScoreField(categoryId) { return `cat:${categoryId}:score` } function buildDailyActionField(categoryId, action) { return `cat:${categoryId}:act:${action}` } async function applyRedisCaps({ redis, userId, categoryId, action, basePoints }) { const userKey = buildDailyUserKey(userId) const actionField = buildDailyActionField(categoryId, action) const categoryScoreField = buildDailyCategoryScoreField(categoryId) const result = await redis.eval( APPLY_CAPS_SCRIPT, 1, userKey, actionField, categoryScoreField, String(basePoints), String(DEFAULT_FULL_LIMIT), String(DEFAULT_HALF_LIMIT), String(DEFAULT_TTL_SECONDS), String(DEFAULT_DAILY_CAP) ) const awarded = Number(Array.isArray(result) ? result[0] : 0) return Number.isFinite(awarded) && awarded > 0 ? Math.floor(awarded) : 0 } async function queueIncrement({ redis, userId, categoryId, points }) { const field = `${userId}:${categoryId}` const key = getUserInterestIncrementHashKeyByUserId(userId) await redis.hincrby(key, field, points) } async function persistFallback({ userId, categoryId, points }) { return userInterestProfileDb.applyInterestIncrementsBatch( [{ userId, categoryId, points }], { saturationRatio: DEFAULT_SATURATION_RATIO } ) } async function trackUserCategoryInterest({ userId, categoryId, action }) { const uid = normalizePositiveInt(userId) const cid = normalizePositiveInt(categoryId) const normalizedAction = normalizeAction(action) if (!uid || !cid || !normalizedAction) return { awarded: 0, queued: false } const basePoints = Number(ACTION_POINTS[normalizedAction] || 0) if (!Number.isInteger(basePoints) || basePoints <= 0) return { awarded: 0, queued: false } const redis = getRedisClient() try { const awarded = await applyRedisCaps({ redis, userId: uid, categoryId: cid, action: normalizedAction, basePoints, }) if (!awarded) return { awarded: 0, queued: false } await queueIncrement({ redis, userId: uid, categoryId: cid, points: awarded, }) return { awarded, queued: true } } catch (err) { try { await persistFallback({ userId: uid, categoryId: cid, points: basePoints }) return { awarded: basePoints, queued: false, fallback: true } } catch { return { awarded: 0, queued: false, fallback: false } } } } function getUserInterestIncrementHashKeyByUserId(userId) { const uid = normalizePositiveInt(userId) if (!uid || DEFAULT_INCREMENT_SHARDS <= 1) return USER_INTEREST_INCREMENT_HASH_KEY const shard = uid % DEFAULT_INCREMENT_SHARDS return `${USER_INTEREST_INCREMENT_HASH_KEY_PREFIX}${shard}` } function getUserInterestIncrementHashKeys() { if (DEFAULT_INCREMENT_SHARDS <= 1) return [USER_INTEREST_INCREMENT_HASH_KEY] const keys = [USER_INTEREST_INCREMENT_HASH_KEY] for (let shard = 0; shard < DEFAULT_INCREMENT_SHARDS; shard += 1) { keys.push(`${USER_INTEREST_INCREMENT_HASH_KEY_PREFIX}${shard}`) } return keys } module.exports = { USER_INTEREST_ACTIONS, USER_INTEREST_INCREMENT_HASH_KEY, getUserInterestIncrementHashKeys, trackUserCategoryInterest, }