162 lines
5.2 KiB
JavaScript
162 lines
5.2 KiB
JavaScript
const prisma = require("./client")
|
|
|
|
const DEFAULT_SATURATION_RATIO = 0.3
|
|
const DEFAULT_TX_USER_CHUNK_SIZE = Math.max(
|
|
1,
|
|
Number(process.env.USER_INTEREST_DB_TX_USER_CHUNK_SIZE) || 200
|
|
)
|
|
|
|
function getDb(db) {
|
|
return db || prisma
|
|
}
|
|
|
|
function normalizePositiveInt(value) {
|
|
const num = Number(value)
|
|
if (!Number.isInteger(num) || num <= 0) return null
|
|
return num
|
|
}
|
|
|
|
function normalizePoints(value) {
|
|
const num = Number(value)
|
|
if (!Number.isFinite(num) || num <= 0) return null
|
|
return Math.floor(num)
|
|
}
|
|
|
|
function normalizeScores(raw) {
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}
|
|
return { ...raw }
|
|
}
|
|
|
|
function normalizeSaturationRatio(value) {
|
|
const num = Number(value)
|
|
if (!Number.isFinite(num)) return DEFAULT_SATURATION_RATIO
|
|
if (num <= 0 || num >= 1) return DEFAULT_SATURATION_RATIO
|
|
return num
|
|
}
|
|
|
|
function getMaxAllowedBySaturation({ currentCategoryScore, totalScore, ratio }) {
|
|
const current = Number(currentCategoryScore) || 0
|
|
const total = Number(totalScore) || 0
|
|
const otherTotal = Math.max(0, total - current)
|
|
if (otherTotal <= 0) return Number.POSITIVE_INFINITY
|
|
return Math.floor((otherTotal * ratio) / (1 - ratio))
|
|
}
|
|
|
|
function aggregateIncrements(increments = []) {
|
|
const map = new Map()
|
|
for (const item of Array.isArray(increments) ? increments : []) {
|
|
const userId = normalizePositiveInt(item?.userId)
|
|
const categoryId = normalizePositiveInt(item?.categoryId)
|
|
const points = normalizePoints(item?.points)
|
|
if (!userId || !categoryId || !points) continue
|
|
const key = `${userId}:${categoryId}`
|
|
map.set(key, (map.get(key) || 0) + points)
|
|
}
|
|
|
|
const groupedByUser = new Map()
|
|
for (const [key, points] of map.entries()) {
|
|
const [userIdRaw, categoryIdRaw] = key.split(":")
|
|
const userId = Number(userIdRaw)
|
|
const categoryId = Number(categoryIdRaw)
|
|
if (!groupedByUser.has(userId)) groupedByUser.set(userId, [])
|
|
groupedByUser.get(userId).push({ categoryId, points })
|
|
}
|
|
|
|
return groupedByUser
|
|
}
|
|
|
|
function chunkEntries(entries = [], size = DEFAULT_TX_USER_CHUNK_SIZE) {
|
|
const normalizedSize = Math.max(1, Number(size) || DEFAULT_TX_USER_CHUNK_SIZE)
|
|
const chunks = []
|
|
for (let i = 0; i < entries.length; i += normalizedSize) {
|
|
chunks.push(entries.slice(i, i + normalizedSize))
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
async function getUserInterestProfile(userId, db) {
|
|
const uid = normalizePositiveInt(userId)
|
|
if (!uid) return null
|
|
const p = getDb(db)
|
|
const rows = await p.$queryRawUnsafe(
|
|
'SELECT "userId", "categoryScores", "totalScore", "createdAt", "updatedAt" FROM "UserInterestProfile" WHERE "userId" = $1 LIMIT 1',
|
|
uid
|
|
)
|
|
return Array.isArray(rows) && rows.length ? rows[0] : null
|
|
}
|
|
|
|
async function applyInterestIncrementsBatch(increments = [], options = {}, db) {
|
|
const groupedByUser = aggregateIncrements(increments)
|
|
if (!groupedByUser.size) {
|
|
return { updated: 0, appliedPoints: 0 }
|
|
}
|
|
|
|
const saturationRatio = normalizeSaturationRatio(options?.saturationRatio)
|
|
let updated = 0
|
|
let appliedPoints = 0
|
|
|
|
const userEntries = Array.from(groupedByUser.entries())
|
|
const chunks = chunkEntries(userEntries)
|
|
|
|
for (const chunk of chunks) {
|
|
await getDb(db).$transaction(async (tx) => {
|
|
for (const [userId, entries] of chunk) {
|
|
await tx.$executeRawUnsafe(
|
|
'INSERT INTO "UserInterestProfile" ("userId", "categoryScores", "totalScore", "createdAt", "updatedAt") VALUES ($1, \'{}\'::jsonb, 0, NOW(), NOW()) ON CONFLICT ("userId") DO NOTHING',
|
|
userId
|
|
)
|
|
const rows = await tx.$queryRawUnsafe(
|
|
'SELECT "userId", "categoryScores", "totalScore" FROM "UserInterestProfile" WHERE "userId" = $1 FOR UPDATE',
|
|
userId
|
|
)
|
|
const profile = Array.isArray(rows) && rows.length ? rows[0] : null
|
|
if (!profile) continue
|
|
|
|
const scores = normalizeScores(profile.categoryScores)
|
|
let totalScore = Number(profile.totalScore || 0)
|
|
let changed = false
|
|
|
|
for (const entry of entries) {
|
|
const categoryKey = String(entry.categoryId)
|
|
const currentCategoryScore = Number(scores[categoryKey] || 0)
|
|
const maxAllowedBySaturation = getMaxAllowedBySaturation({
|
|
currentCategoryScore,
|
|
totalScore,
|
|
ratio: saturationRatio,
|
|
})
|
|
|
|
let nextCategoryScore = currentCategoryScore + entry.points
|
|
if (Number.isFinite(maxAllowedBySaturation)) {
|
|
nextCategoryScore = Math.min(nextCategoryScore, maxAllowedBySaturation)
|
|
}
|
|
|
|
const applied = Math.max(0, Math.floor(nextCategoryScore - currentCategoryScore))
|
|
if (applied <= 0) continue
|
|
|
|
scores[categoryKey] = currentCategoryScore + applied
|
|
totalScore += applied
|
|
appliedPoints += applied
|
|
changed = true
|
|
}
|
|
|
|
if (!changed) continue
|
|
|
|
await tx.$executeRawUnsafe(
|
|
'UPDATE "UserInterestProfile" SET "categoryScores" = $1::jsonb, "totalScore" = $2, "updatedAt" = NOW() WHERE "userId" = $3',
|
|
JSON.stringify(scores),
|
|
totalScore,
|
|
userId
|
|
)
|
|
updated += 1
|
|
}
|
|
})
|
|
}
|
|
|
|
return { updated, appliedPoints }
|
|
}
|
|
|
|
module.exports = {
|
|
getUserInterestProfile,
|
|
applyInterestIncrementsBatch,
|
|
}
|