229805a070
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>
313 lines
10 KiB
JavaScript
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,
|
|
},
|
|
};
|
|
}
|