Files
memind/plaza-events.mjs
john 229805a070 Improve WeChat MP replies and ship MindSpace/H5 production updates.
Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 23:06:43 +08:00

313 lines
10 KiB
JavaScript

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