Files
memind/plaza-interactions.mjs
T
John 2e14873f2d Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 15:04:43 -07:00

514 lines
17 KiB
JavaScript

import crypto from 'node:crypto';
const REACTION_TYPES = new Set(['like', 'collect', 'share']);
const COUNTER_BY_TYPE = {
like: 'like_count',
collect: 'collect_count',
share: 'share_count',
};
function plazaError(message, code, details) {
return Object.assign(new Error(message), { code, details });
}
function clampLimit(value, fallback = 20, max = 50) {
const limit = Number(value ?? fallback);
if (!Number.isFinite(limit) || limit < 1) return fallback;
return Math.min(Math.floor(limit), max);
}
function normalizeReactionType(value) {
const type = String(value ?? '').trim();
if (!REACTION_TYPES.has(type)) throw plazaError('不支持的互动类型', 'invalid_input');
return type;
}
function formatCommentAuthor(row) {
return {
user_id: row.user_id,
slug: row.author_slug || row.slug || row.username,
display_name: row.author_display_name || row.display_name || row.username,
avatar_url: '',
};
}
export const interactionInternals = {
clampLimit,
normalizeReactionType,
};
export function createPlazaInteractionService(
pool,
{ idFactory = () => crypto.randomUUID(), formatPostRow, plazaRedis = null } = {},
) {
const requirePublishedPost = async (postId, conn = pool) => {
const [rows] = await conn.query(
`SELECT * FROM plaza_posts WHERE id = ? AND status = 'published' LIMIT 1`,
[postId],
);
if (!rows[0]) throw plazaError('帖子不存在或已隐藏', 'POST_NOT_FOUND');
return rows[0];
};
const loadViewerReactions = async (viewerId, postIds) => {
const map = new Map();
if (!viewerId || postIds.length === 0) return map;
const placeholders = postIds.map(() => '?').join(', ');
const [rows] = await pool.query(
`SELECT post_id, type FROM plaza_reactions
WHERE user_id = ? AND post_id IN (${placeholders})`,
[viewerId, ...postIds],
);
for (const id of postIds) {
map.set(id, { liked: false, collected: false });
}
for (const row of rows) {
const current = map.get(row.post_id) ?? { liked: false, collected: false };
if (row.type === 'like') current.liked = true;
if (row.type === 'collect') current.collected = true;
map.set(row.post_id, current);
}
return map;
};
const bumpCounter = async (conn, postId, field, delta) => {
const now = Date.now();
if (delta > 0) {
await conn.query(
`UPDATE plaza_posts SET ${field} = ${field} + ?, updated_at = ? WHERE id = ?`,
[delta, now, postId],
);
return;
}
await conn.query(
`UPDATE plaza_posts SET ${field} = GREATEST(CAST(${field} AS SIGNED) + ?, 0), updated_at = ? WHERE id = ?`,
[delta, now, postId],
);
};
const addReaction = async (userId, postId, typeInput) => {
const type = normalizeReactionType(typeInput);
const field = COUNTER_BY_TYPE[type];
await requirePublishedPost(postId);
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [existing] = await conn.query(
`SELECT id FROM plaza_reactions WHERE post_id = ? AND user_id = ? AND type = ? LIMIT 1`,
[postId, userId, type],
);
if (!existing[0]) {
await conn.query(
`INSERT INTO plaza_reactions (id, post_id, user_id, type, created_at) VALUES (?, ?, ?, ?, ?)`,
[idFactory(), postId, userId, type, Date.now()],
);
await bumpCounter(conn, postId, field, 1);
}
await conn.commit();
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
return { post_id: postId, type, active: true };
};
const removeReaction = async (userId, postId, typeInput) => {
const type = normalizeReactionType(typeInput);
const field = COUNTER_BY_TYPE[type];
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [result] = await conn.query(
`DELETE FROM plaza_reactions WHERE post_id = ? AND user_id = ? AND type = ?`,
[postId, userId, type],
);
if (result.affectedRows > 0) {
await bumpCounter(conn, postId, field, -1);
}
await conn.commit();
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
return { post_id: postId, type, active: false };
};
const listComments = async (
postId,
{ cursor = null, limit = 20, parentId = null, viewerId = null } = {},
) => {
await requirePublishedPost(postId);
const pageLimit = clampLimit(limit);
const params = [postId, 'visible'];
let parentClause = 'AND c.parent_id IS NULL';
if (parentId) {
parentClause = 'AND c.parent_id = ?';
params.push(parentId);
}
let cursorClause = '';
if (cursor) {
cursorClause =
'AND c.created_at < (SELECT created_at FROM plaza_comments WHERE id = ? LIMIT 1)';
params.push(cursor);
}
params.push(pageLimit + 1);
const [rows] = await pool.query(
`SELECT c.*, u.slug AS author_slug, u.username, u.display_name AS author_display_name
FROM plaza_comments c
JOIN h5_users u ON u.id = c.user_id
WHERE c.post_id = ? AND c.status = ? ${parentClause} ${cursorClause}
ORDER BY c.created_at DESC
LIMIT ?`,
params,
);
const hasMore = rows.length > pageLimit;
const pageRows = hasMore ? rows.slice(0, pageLimit) : rows;
let likedSet = new Set();
if (viewerId && pageRows.length > 0) {
const ids = pageRows.map((row) => row.id);
const [likedRows] = await pool.query(
`SELECT comment_id FROM plaza_comment_reactions
WHERE user_id = ? AND comment_id IN (${ids.map(() => '?').join(', ')})`,
[viewerId, ...ids],
);
likedSet = new Set(likedRows.map((row) => row.comment_id));
}
const comments = pageRows.map((row) => ({
id: row.id,
content: row.content,
author: formatCommentAuthor(row),
like_count: Number(row.like_count ?? 0),
reply_count: Number(row.reply_count ?? 0),
viewer_liked: viewerId ? likedSet.has(row.id) : false,
created_at: new Date(Number(row.created_at)).toISOString(),
status: row.status,
parent_id: row.parent_id,
}));
return {
comments,
next_cursor: hasMore ? pageRows[pageRows.length - 1].id : null,
has_more: hasMore,
};
};
const createComment = async (userId, postId, { content, parent_id: parentId = null }) => {
const text = String(content ?? '').trim();
if (!text) throw plazaError('评论不能为空', 'invalid_input');
if (text.length > 500) throw plazaError('评论超出 500 字符', 'COMMENT_TOO_LONG');
const post = await requirePublishedPost(postId);
if (!post.allow_comment) throw plazaError('该帖子已关闭评论', 'COMMENT_DISABLED');
const [users] = await pool.query(
`SELECT plaza_comment_banned FROM h5_users WHERE id = ? LIMIT 1`,
[userId],
);
if (users[0]?.plaza_comment_banned) {
throw plazaError('无评论权限', 'POST_PERMISSION_DENIED');
}
if (plazaRedis?.checkCommentRateLimit) {
const rate = await plazaRedis.checkCommentRateLimit(userId, postId);
if (!rate.allowed) {
throw plazaError('评论过于频繁,请稍后再试', 'COMMENT_RATE_LIMITED');
}
} else {
const hourAgo = Date.now() - 3_600_000;
const [recent] = await pool.query(
`SELECT COUNT(*) AS count FROM plaza_comments
WHERE user_id = ? AND post_id = ? AND created_at >= ? AND status = 'visible'`,
[userId, postId, hourAgo],
);
if (Number(recent[0]?.count ?? 0) >= 10) {
throw plazaError('评论过于频繁,请稍后再试', 'COMMENT_RATE_LIMITED');
}
}
let parentComment = null;
if (parentId) {
const [parents] = await pool.query(
`SELECT * FROM plaza_comments WHERE id = ? AND post_id = ? AND status = 'visible' LIMIT 1`,
[parentId, postId],
);
parentComment = parents[0];
if (!parentComment) throw plazaError('父评论不存在', 'COMMENT_NOT_FOUND');
if (parentComment.parent_id) {
throw plazaError('不允许三级嵌套回复', 'REPLY_DEPTH_EXCEEDED');
}
}
const now = Date.now();
const commentId = idFactory();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO plaza_comments
(id, post_id, user_id, parent_id, content, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'visible', ?, ?)`,
[commentId, postId, userId, parentId, text, now, now],
);
if (parentComment) {
await conn.query(
`UPDATE plaza_comments SET reply_count = reply_count + 1, updated_at = ? WHERE id = ?`,
[now, parentId],
);
} else {
await conn.query(
`UPDATE plaza_posts SET comment_count = comment_count + 1, updated_at = ? WHERE id = ?`,
[now, postId],
);
}
await conn.commit();
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
await plazaRedis?.markCommentRateLimit?.(userId, postId);
return { id: commentId };
};
const deleteComment = async (userId, commentId) => {
const [rows] = await pool.query(
`SELECT * FROM plaza_comments WHERE id = ? AND user_id = ? LIMIT 1`,
[commentId, userId],
);
if (!rows[0]) throw plazaError('评论不存在', 'COMMENT_NOT_FOUND');
const now = Date.now();
await pool.query(
`UPDATE plaza_comments
SET content = '', status = 'deleted', deleted_by = 'user', updated_at = ?
WHERE id = ?`,
[now, commentId],
);
return { id: commentId, status: 'deleted' };
};
const toggleCommentLike = async (userId, commentId, liked) => {
const [rows] = await pool.query(
`SELECT c.* FROM plaza_comments c
JOIN plaza_posts p ON p.id = c.post_id
WHERE c.id = ? AND c.status = 'visible' AND p.status = 'published'
LIMIT 1`,
[commentId],
);
if (!rows[0]) throw plazaError('评论不存在', 'COMMENT_NOT_FOUND');
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
if (liked) {
const [existing] = await conn.query(
`SELECT id FROM plaza_comment_reactions WHERE comment_id = ? AND user_id = ? LIMIT 1`,
[commentId, userId],
);
if (!existing[0]) {
await conn.query(
`INSERT INTO plaza_comment_reactions (id, comment_id, user_id, created_at) VALUES (?, ?, ?, ?)`,
[idFactory(), commentId, userId, Date.now()],
);
await conn.query(
`UPDATE plaza_comments SET like_count = like_count + 1, updated_at = ? WHERE id = ?`,
[Date.now(), commentId],
);
}
} else {
const [result] = await conn.query(
`DELETE FROM plaza_comment_reactions WHERE comment_id = ? AND user_id = ?`,
[commentId, userId],
);
if (result.affectedRows > 0) {
await conn.query(
`UPDATE plaza_comments SET like_count = GREATEST(CAST(like_count AS SIGNED) - 1, 0), updated_at = ? WHERE id = ?`,
[Date.now(), commentId],
);
}
}
await conn.commit();
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
return { id: commentId, liked };
};
const resolveUserBySlug = async (slug) => {
const [rows] = await pool.query(
`SELECT id, slug, username, display_name,
plaza_post_count, plaza_follower_count, plaza_following_count
FROM h5_users WHERE slug = ? OR username = ? LIMIT 1`,
[slug, slug],
);
const row = rows[0];
if (!row) throw plazaError('用户不存在', 'user_not_found');
return row;
};
const followUser = async (followerId, followeeSlug) => {
const followee = await resolveUserBySlug(followeeSlug);
if (followee.id === followerId) throw plazaError('不能关注自己', 'SELF_FOLLOW');
const now = Date.now();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [result] = await conn.query(
`INSERT IGNORE INTO plaza_follows (follower_id, followee_id, created_at) VALUES (?, ?, ?)`,
[followerId, followee.id, now],
);
if (result.affectedRows > 0) {
await conn.query(
`UPDATE h5_users SET plaza_following_count = plaza_following_count + 1, updated_at = ? WHERE id = ?`,
[now, followerId],
);
await conn.query(
`UPDATE h5_users SET plaza_follower_count = plaza_follower_count + 1, updated_at = ? WHERE id = ?`,
[now, followee.id],
);
}
await conn.commit();
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
return { following: true };
};
const unfollowUser = async (followerId, followeeSlug) => {
const followee = await resolveUserBySlug(followeeSlug);
const now = Date.now();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [result] = await conn.query(
`DELETE FROM plaza_follows WHERE follower_id = ? AND followee_id = ?`,
[followerId, followee.id],
);
if (result.affectedRows > 0) {
await conn.query(
`UPDATE h5_users SET plaza_following_count = GREATEST(CAST(plaza_following_count AS SIGNED) - 1, 0), updated_at = ? WHERE id = ?`,
[now, followerId],
);
await conn.query(
`UPDATE h5_users SET plaza_follower_count = GREATEST(CAST(plaza_follower_count AS SIGNED) - 1, 0), updated_at = ? WHERE id = ?`,
[now, followee.id],
);
}
await conn.commit();
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
return { following: false };
};
const getUserProfile = async (slug, viewerId = null) => {
const user = await resolveUserBySlug(slug);
const [likeRows] = await pool.query(
`SELECT COALESCE(SUM(like_count), 0) AS total_likes
FROM plaza_posts WHERE user_id = ? AND status = 'published'`,
[user.id],
);
const [recentRows] = await pool.query(
`SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon
FROM plaza_posts pp
JOIN plaza_categories c ON c.id = pp.category_id
WHERE pp.user_id = ? AND pp.status = 'published'
ORDER BY pp.published_at DESC
LIMIT 6`,
[user.id],
);
let viewerFollowing = false;
if (viewerId && viewerId !== user.id) {
const [followRows] = await pool.query(
`SELECT 1 FROM plaza_follows WHERE follower_id = ? AND followee_id = ? LIMIT 1`,
[viewerId, user.id],
);
viewerFollowing = followRows.length > 0;
}
const recentPosts = recentRows.map((row) =>
formatPostRow ? formatPostRow(row, { viewerReacted: null }) : row,
);
return {
user: {
user_id: user.id,
slug: user.slug || user.username,
display_name: user.display_name || user.username,
avatar_url: '',
bio: '',
stats: {
post_count: Number(user.plaza_post_count ?? 0),
follower_count: Number(user.plaza_follower_count ?? 0),
following_count: Number(user.plaza_following_count ?? 0),
total_likes: Number(likeRows[0]?.total_likes ?? 0),
},
viewer_following: viewerId ? viewerFollowing : undefined,
},
recent_posts: recentPosts,
};
};
const listUserPosts = async (slug, { cursor = null, limit = 20, viewerId = null } = {}) => {
const user = await resolveUserBySlug(slug);
const pageLimit = clampLimit(limit);
const params = [user.id, 'published'];
let cursorClause = '';
if (cursor) {
cursorClause =
'AND (pp.published_at < (SELECT published_at FROM plaza_posts WHERE id = ? LIMIT 1) OR (pp.published_at = (SELECT published_at FROM plaza_posts WHERE id = ? LIMIT 1) AND pp.id < ?))';
params.push(cursor, cursor, cursor);
}
params.push(pageLimit + 1);
const [rows] = await pool.query(
`SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon
FROM plaza_posts pp
JOIN plaza_categories c ON c.id = pp.category_id
WHERE pp.user_id = ? AND pp.status = ? ${cursorClause}
ORDER BY pp.published_at DESC, pp.id DESC
LIMIT ?`,
params,
);
const hasMore = rows.length > pageLimit;
const pageRows = hasMore ? rows.slice(0, pageLimit) : rows;
const reactions = await loadViewerReactions(
viewerId,
pageRows.map((row) => row.id),
);
const posts = pageRows.map((row) =>
formatPostRow
? formatPostRow(row, { viewerReacted: viewerId ? reactions.get(row.id) ?? null : null })
: row,
);
return {
posts,
next_cursor: hasMore ? pageRows[pageRows.length - 1].id : null,
has_more: hasMore,
};
};
return {
loadViewerReactions,
addReaction,
removeReaction,
listComments,
createComment,
deleteComment,
toggleCommentLike,
followUser,
unfollowUser,
getUserProfile,
listUserPosts,
};
}