271 lines
8.4 KiB
TypeScript
271 lines
8.4 KiB
TypeScript
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<string>();
|
|
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 }
|
|
);
|
|
}
|