HotTRDealsBackend/db/userInterestProfile.db.js
2026-02-09 21:47:55 +00:00

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