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