import crypto from 'node:crypto'; const PROFILE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; const PROFILE_CACHE_TTL_MS = 45_000; export const PLAZA_EVENT_TYPES = new Set([ 'impression', 'click', 'view', 'dwell', 'like', 'collect', 'comment', 'share', 'dislike', 'hide', ]); export const PLAZA_EVENT_WEIGHTS = { impression: 0.06, click: 0.85, view: 1.25, dwell: 1.0, like: 5.5, collect: 6.5, comment: 4.5, share: 3.5, dislike: -9.0, hide: -6.0, }; const DWELL_THRESHOLDS = [ { ms: 30_000, boost: 4.0 }, { ms: 10_000, boost: 2.5 }, { ms: 3_000, boost: 1.5 }, ]; function plazaError(message, code) { return Object.assign(new Error(message), { code }); } const DEEP_SEEN_EVENTS = new Set([ 'click', 'view', 'dwell', 'like', 'collect', 'comment', 'share', ]); function emptyProfile() { return { profileVersion: 1, categoryWeights: {}, tagWeights: {}, authorWeights: {}, feedCategoryWeights: {}, seenPostIds: new Set(), deepSeenPostIds: new Set(), dislikedPostIds: new Set(), dislikedCategorySlugs: new Set(), likedPostIds: new Set(), followedAuthorIds: new Set(), eventCount: 0, }; } function decayFactor(ageMs, halfLifeMs) { if (ageMs <= 0) return 1; return Math.pow(0.5, ageMs / halfLifeMs); } function bumpWeight(map, key, delta) { if (!key) return; map[key] = (map[key] ?? 0) + delta; } function normalizeWeights(map) { const entries = Object.entries(map); if (entries.length === 0) return map; const max = Math.max(...entries.map(([, value]) => value), 1e-6); return Object.fromEntries(entries.map(([key, value]) => [key, value / max])); } function dwellBoost(dwellMs) { const ms = Number(dwellMs ?? 0); if (!Number.isFinite(ms) || ms <= 0) return 0; for (const tier of DWELL_THRESHOLDS) { if (ms >= tier.ms) return tier.boost; } return 0; } function eventSignal(event) { let weight = PLAZA_EVENT_WEIGHTS[event.event_type] ?? 0; if (event.event_type === 'dwell') { weight *= 1 + dwellBoost(event.dwell_ms); } return weight; } export function createPlazaEventService(pool, { idFactory = () => crypto.randomUUID() } = {}) { const profileCache = new Map(); const cacheKey = ({ userId, sessionId }) => userId ? `u:${userId}` : sessionId ? `s:${sessionId}` : 'anon'; const invalidateProfileCache = ({ userId, sessionId }) => { profileCache.delete(cacheKey({ userId, sessionId })); if (userId) profileCache.delete(`u:${userId}`); if (sessionId) profileCache.delete(`s:${sessionId}`); }; const recordEvents = async ({ userId = null, sessionId, events = [], }) => { if (!sessionId) throw plazaError('session_id 不能为空', 'invalid_input'); const now = Date.now(); const normalized = []; for (const raw of events) { const eventType = String(raw?.event_type ?? raw?.type ?? '').trim(); if (!PLAZA_EVENT_TYPES.has(eventType)) continue; const postId = String(raw?.post_id ?? '').trim(); if (!postId) continue; normalized.push({ id: idFactory(), userId, sessionId, postId, eventType, dwellMs: raw?.dwell_ms == null || raw?.dwell_ms === '' ? null : Math.max(0, Math.min(3_600_000, Number(raw.dwell_ms))), feedSort: String(raw?.feed_sort ?? '').slice(0, 32), feedCategory: String(raw?.feed_category ?? '').slice(0, 64), position: raw?.position == null || raw?.position === '' ? null : Math.max(0, Math.floor(Number(raw.position))), createdAt: Number(raw?.created_at) > 0 ? Number(raw.created_at) : now, }); } if (normalized.length === 0) { return { accepted: 0 }; } const values = normalized.map((event) => [ event.id, event.userId, event.sessionId, event.postId, event.eventType, event.dwellMs, event.feedSort, event.feedCategory, event.position, event.createdAt, ]); await pool.query( `INSERT INTO plaza_user_events (id, user_id, session_id, post_id, event_type, dwell_ms, feed_sort, feed_category, position, created_at) VALUES ?`, [values], ); invalidateProfileCache({ userId, sessionId }); return { accepted: normalized.length }; }; const buildProfileFromSignals = async ({ userId, sessionId, halfLifeMs }) => { const profile = emptyProfile(); const since = Date.now() - PROFILE_WINDOW_MS; const params = [since]; let identityClause = ''; if (userId) { identityClause = 'AND (e.user_id = ? OR e.session_id = ?)'; params.push(userId, sessionId); } else { identityClause = 'AND e.session_id = ?'; params.push(sessionId); } const [eventRows] = await pool.query( `SELECT e.event_type, e.dwell_ms, e.post_id, e.created_at, e.feed_category, c.slug AS category_slug, pp.tags, pp.user_id AS author_id FROM plaza_user_events e JOIN plaza_posts pp ON pp.id = e.post_id JOIN plaza_categories c ON c.id = pp.category_id WHERE e.created_at >= ? ${identityClause}`, params, ); const now = Date.now(); for (const row of eventRows) { profile.eventCount += 1; profile.seenPostIds.add(row.post_id); if (DEEP_SEEN_EVENTS.has(row.event_type)) { profile.deepSeenPostIds.add(row.post_id); } const ageMs = now - Number(row.created_at); const decay = decayFactor(ageMs, halfLifeMs); const signal = eventSignal({ event_type: row.event_type, dwell_ms: row.dwell_ms }) * decay; const feedCategory = String(row.feed_category ?? '').trim(); if ( feedCategory && (row.event_type === 'impression' || row.event_type === 'click') ) { bumpWeight(profile.feedCategoryWeights, feedCategory, Math.max(signal, 0.12) * decay); } if (row.event_type === 'dislike' || row.event_type === 'hide') { profile.dislikedPostIds.add(row.post_id); profile.dislikedCategorySlugs.add(row.category_slug); bumpWeight(profile.categoryWeights, row.category_slug, signal * 0.35); continue; } bumpWeight(profile.categoryWeights, row.category_slug, signal); const tags = Array.isArray(row.tags) ? row.tags : JSON.parse(row.tags ?? '[]'); for (const tag of tags) bumpWeight(profile.tagWeights, String(tag).toLowerCase(), signal * 0.65); bumpWeight(profile.authorWeights, row.author_id, signal * 0.8); if (row.event_type === 'like') profile.likedPostIds.add(row.post_id); if (row.event_type === 'collect') profile.likedPostIds.add(row.post_id); } if (userId) { const [reactions] = await pool.query( `SELECT r.type, r.post_id, c.slug AS category_slug, pp.tags, pp.user_id AS author_id, r.created_at FROM plaza_reactions r JOIN plaza_posts pp ON pp.id = r.post_id AND pp.status = 'published' JOIN plaza_categories c ON c.id = pp.category_id WHERE r.user_id = ? AND r.created_at >= ?`, [userId, since], ); for (const row of reactions) { const ageMs = now - Number(row.created_at); const decay = decayFactor(ageMs, halfLifeMs); const signal = (row.type === 'collect' ? 6.5 : row.type === 'share' ? 3.5 : 5.5) * decay; bumpWeight(profile.categoryWeights, row.category_slug, signal); const tags = Array.isArray(row.tags) ? row.tags : JSON.parse(row.tags ?? '[]'); for (const tag of tags) bumpWeight(profile.tagWeights, String(tag).toLowerCase(), signal * 0.65); bumpWeight(profile.authorWeights, row.author_id, signal); profile.likedPostIds.add(row.post_id); profile.seenPostIds.add(row.post_id); } const [follows] = await pool.query( `SELECT followee_id FROM plaza_follows WHERE follower_id = ?`, [userId], ); for (const row of follows) { profile.followedAuthorIds.add(row.followee_id); bumpWeight(profile.authorWeights, row.followee_id, 4.0); } const [comments] = await pool.query( `SELECT c.post_id, cat.slug AS category_slug, pp.tags, pp.user_id AS author_id, c.created_at FROM plaza_comments c JOIN plaza_posts pp ON pp.id = c.post_id AND pp.status = 'published' JOIN plaza_categories cat ON cat.id = pp.category_id WHERE c.user_id = ? AND c.status = 'visible' AND c.created_at >= ?`, [userId, since], ); for (const row of comments) { const ageMs = now - Number(row.created_at); const decay = decayFactor(ageMs, halfLifeMs); const signal = 4.5 * decay; bumpWeight(profile.categoryWeights, row.category_slug, signal); const tags = Array.isArray(row.tags) ? row.tags : JSON.parse(row.tags ?? '[]'); for (const tag of tags) bumpWeight(profile.tagWeights, String(tag).toLowerCase(), signal * 0.65); bumpWeight(profile.authorWeights, row.author_id, signal * 0.85); profile.seenPostIds.add(row.post_id); } } profile.categoryWeights = normalizeWeights(profile.categoryWeights); profile.tagWeights = normalizeWeights(profile.tagWeights); profile.authorWeights = normalizeWeights(profile.authorWeights); profile.feedCategoryWeights = normalizeWeights(profile.feedCategoryWeights); profile.profileVersion = 1 + profile.eventCount + profile.likedPostIds.size * 3 + profile.dislikedPostIds.size * 5; return profile; }; const getProfile = async ({ userId = null, sessionId, halfLifeMs = 7 * 24 * 60 * 60 * 1000 }) => { if (!userId && !sessionId) return emptyProfile(); const key = cacheKey({ userId, sessionId }); const cached = profileCache.get(key); if (cached && cached.expiresAt > Date.now()) { return cached.profile; } const profile = await buildProfileFromSignals({ userId, sessionId, halfLifeMs }); profileCache.set(key, { profile, expiresAt: Date.now() + PROFILE_CACHE_TTL_MS }); return profile; }; return { recordEvents, getProfile, invalidateProfileCache, internals: { emptyProfile, decayFactor, eventSignal, bumpWeight, normalizeWeights, PLAZA_EVENT_WEIGHTS, }, }; }