2e14873f2d
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
514 lines
17 KiB
JavaScript
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,
|
|
};
|
|
}
|