205 lines
6.1 KiB
JavaScript
205 lines
6.1 KiB
JavaScript
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,
|
|
}
|