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

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