import { prisma } from "@/lib/prisma"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { NextResponse } from "next/server"; import { rewardPoints } from "@/lib/points-reward"; import { triggerAchievementCheck } from "@/lib/achievements"; const MENTION_REGEX = /@([\w一-龥]{1,30})/g; function extractMentionNames(content: string): string[] { const matches = Array.from(content.matchAll(MENTION_REGEX)); const set = new Set(); for (const m of matches) { if (m[1]) set.add(m[1]); } return Array.from(set); } export async function GET(req: Request, { params }: { params: { id: string } }) { const topicId = parseInt(params.id); const { searchParams } = new URL(req.url); const page = parseInt(searchParams.get("page") || "1", 10); const pageSize = parseInt(searchParams.get("pageSize") || "20", 10); const [posts, totalCount] = await Promise.all([ prisma.forumPost.findMany({ where: { topicId }, include: { user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } }, replyTo: { select: { id: true, content: true, user: { select: { id: true, name: true, email: true } }, }, }, }, orderBy: { createdAt: "asc" }, skip: (page - 1) * pageSize, take: pageSize, }), prisma.forumPost.count({ where: { topicId } }), ]); const formatted = posts.map((p) => ({ id: p.id, content: p.content, isAnswer: p.isAnswer, replyToId: p.replyToId, replyToSnapshot: p.replyToSnapshot, replyTo: p.replyTo ? { id: p.replyTo.id, content: p.replyTo.content.slice(0, 200), userName: p.replyTo.user.name || p.replyTo.user.email.split("@")[0], } : null, createdAt: p.createdAt, updatedAt: p.updatedAt, user: { id: p.user.id, name: p.user.name || p.user.email.split("@")[0], avatarUrl: p.user.avatarUrl, level: p.user.level, points: p.user.points, }, })); const res = NextResponse.json({ posts: formatted, totalCount, page, pageSize }); res.headers.set("Cache-Control", "public, s-maxage=30, stale-while-revalidate=120"); return res; } export async function POST(req: Request, { params }: { params: { id: string } }) { const session = await getServerSession(authOptions); if (!session?.user?.id) { return NextResponse.json({ error: "请先登录" }, { status: 401 }); } const topicId = parseInt(params.id); const userId = parseInt(session.user.id); const body = await req.json(); const { content, replyToId } = body as { content?: string; replyToId?: number | null }; if (!content || !content.trim()) { return NextResponse.json({ error: "回复内容不能为空" }, { status: 400 }); } if (content.trim().length > 5000) { return NextResponse.json({ error: "回复不能超过5000个字符" }, { status: 400 }); } const topic = await prisma.forumTopic.findUnique({ where: { id: topicId }, select: { id: true, title: true, userId: true, isLocked: true }, }); if (!topic) { return NextResponse.json({ error: "话题不存在" }, { status: 404 }); } if (topic.isLocked) { return NextResponse.json({ error: "话题已锁定" }, { status: 400 }); } // 处理 replyToId:必须属于同一话题 let replyToSnapshot: string | null = null; if (replyToId) { const target = await prisma.forumPost.findUnique({ where: { id: replyToId }, include: { user: { select: { id: true, name: true, email: true } } }, }); if (!target || target.topicId !== topicId) { return NextResponse.json({ error: "引用目标不存在" }, { status: 400 }); } const targetName = target.user.name || target.user.email.split("@")[0]; const snippet = target.content.slice(0, 200).replace(/\s+/g, " ").trim(); replyToSnapshot = `@${targetName}: ${snippet}`; } // 解析 @mention 用户 const mentionNames = extractMentionNames(content); const mentionedUsers = mentionNames.length ? await prisma.user.findMany({ where: { name: { in: mentionNames } }, select: { id: true, name: true, email: true }, take: 10, }) : []; const actorName = session.user.name || session.user.email?.split("@")[0] || "用户"; const post = await prisma.$transaction(async (tx) => { const newPost = await tx.forumPost.create({ data: { topicId, userId, content: content.trim(), replyToId: replyToId || null, replyToSnapshot, }, include: { user: { select: { id: true, name: true, email: true, avatarUrl: true, level: true, points: true } }, replyTo: { select: { id: true, content: true, user: { select: { id: true, name: true, email: true } }, }, }, }, }); const now = new Date(); await tx.forumTopic.update({ where: { id: topicId }, data: { replyCount: { increment: 1 }, lastReplyAt: now, updatedAt: now, }, }); // 收集需要通知的目标 const notifs: Array<{ userId: number; type: "TOPIC_REPLY" | "MENTION"; title: string; content?: string; link?: string; }> = []; // 1) 通知楼主(自己回复自己就不通知) if (topic.userId !== userId) { notifs.push({ userId: topic.userId, type: "TOPIC_REPLY", title: `${actorName} 回复了你的话题《${topic.title.slice(0, 30)}》`, content: content.trim().slice(0, 100), link: `/community/topic/${topicId}#post-${newPost.id}`, }); } // 2) 通知被引用的人(如果不是自己) if (replyToId) { const target = await tx.forumPost.findUnique({ where: { id: replyToId }, select: { userId: true }, }); if (target && target.userId !== userId && target.userId !== topic.userId) { notifs.push({ userId: target.userId, type: "MENTION", title: `${actorName} 回复了你的回复`, content: content.trim().slice(0, 100), link: `/community/topic/${topicId}#post-${newPost.id}`, }); } } // 3) 通知 @mention 的用户(不是自己、不重复) const notifiedSet = new Set(notifs.map((n) => n.userId)); for (const u of mentionedUsers) { if (u.id === userId) continue; if (notifiedSet.has(u.id)) continue; notifs.push({ userId: u.id, type: "MENTION", title: `${actorName} 在话题《${topic.title.slice(0, 30)}》中提到了你`, content: content.trim().slice(0, 100), link: `/community/topic/${topicId}#post-${newPost.id}`, }); notifiedSet.add(u.id); } // 4) 通知订阅者(不是作者本人、不是楼主、不是已通知的人) const subscribers = await tx.forumTopicSubscription.findMany({ where: { topicId, notifyReply: true, userId: { not: userId }, }, select: { userId: true }, }); for (const s of subscribers) { if (s.userId === topic.userId) continue; if (notifiedSet.has(s.userId)) continue; notifs.push({ userId: s.userId, type: "TOPIC_REPLY", title: `你订阅的话题《${topic.title.slice(0, 30)}》有新回复`, content: content.trim().slice(0, 100), link: `/community/topic/${topicId}#post-${newPost.id}`, }); notifiedSet.add(s.userId); } if (notifs.length > 0) { await tx.notification.createMany({ data: notifs }); } return newPost; }); await rewardPoints(userId, "forum_post", `回复话题`, "topic", topicId); return NextResponse.json( { id: post.id, content: post.content, isAnswer: post.isAnswer, replyToId: post.replyToId, replyToSnapshot: post.replyToSnapshot, replyTo: post.replyTo ? { id: post.replyTo.id, content: post.replyTo.content.slice(0, 200), userName: post.replyTo.user.name || post.replyTo.user.email.split("@")[0], } : null, createdAt: post.createdAt, updatedAt: post.updatedAt, user: { id: post.user.id, name: post.user.name || post.user.email.split("@")[0], avatarUrl: post.user.avatarUrl, level: post.user.level, points: post.user.points, }, }, { status: 201 } ); }