refactor: 引入 apiHandler + ApiError,消除 20 个路由的 try/catch 样板

- 新增 src/lib/api.ts:ApiError 错误类 + apiHandler 统一包装器
- 20 个 API 路由统一使用 apiHandler,删除重复的 try/catch 块
- 验证错误改用 throw new ApiError(),减少嵌套层级
- join/manage 路由的错误码映射改为直接抛出 ApiError
- 删除已无引用的 errorResponse 辅助函数
- 净减 273 行代码
This commit is contained in:
2026-02-26 18:08:47 +08:00
parent d4c6da57a1
commit 0595887480
22 changed files with 626 additions and 863 deletions
+7 -12
View File
@@ -1,27 +1,22 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { apiHandler, ApiError } from "@/lib/api";
export async function POST(req: NextRequest) { export const POST = apiHandler(async (req) => {
const { username, password } = await req.json(); const { username, password } = await req.json();
if (!username || !password) { if (!username || !password) throw new ApiError("请输入用户名和密码");
return NextResponse.json({ error: "请输入用户名和密码" }, { status: 400 });
}
const user = await prisma.user.findUnique({ where: { username: username.trim() } }); const user = await prisma.user.findUnique({ where: { username: username.trim() } });
if (!user) { if (!user) throw new ApiError("用户名或密码错误", 401);
return NextResponse.json({ error: "用户名或密码错误" }, { status: 401 });
}
const valid = await bcrypt.compare(password, user.passwordHash); const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) { if (!valid) throw new ApiError("用户名或密码错误", 401);
return NextResponse.json({ error: "用户名或密码错误" }, { status: 401 });
}
return NextResponse.json({ return NextResponse.json({
id: user.id, id: user.id,
username: user.username, username: user.username,
avatar: user.avatar, avatar: user.avatar,
}); });
} });
+8 -14
View File
@@ -1,27 +1,21 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { apiHandler, ApiError } from "@/lib/api";
export async function POST(req: NextRequest) { export const POST = apiHandler(async (req) => {
const { username, password, avatar } = await req.json(); const { username, password, avatar } = await req.json();
if (!username || !password) { if (!username || !password) throw new ApiError("用户名和密码为必填项");
return NextResponse.json({ error: "用户名和密码为必填项" }, { status: 400 });
}
const trimmedUsername = username.trim(); const trimmedUsername = username.trim();
if (trimmedUsername.length < 2 || trimmedUsername.length > 16) { if (trimmedUsername.length < 2 || trimmedUsername.length > 16) {
return NextResponse.json({ error: "用户名需要 2-16 个字符" }, { status: 400 }); throw new ApiError("用户名需要 2-16 个字符");
}
if (password.length < 6) {
return NextResponse.json({ error: "密码至少 6 个字符" }, { status: 400 });
} }
if (password.length < 6) throw new ApiError("密码至少 6 个字符");
const existing = await prisma.user.findUnique({ where: { username: trimmedUsername } }); const existing = await prisma.user.findUnique({ where: { username: trimmedUsername } });
if (existing) { if (existing) throw new ApiError("用户名已被注册", 409);
return NextResponse.json({ error: "用户名已被注册" }, { status: 409 });
}
const passwordHash = await bcrypt.hash(password, 10); const passwordHash = await bcrypt.hash(password, 10);
@@ -38,4 +32,4 @@ export async function POST(req: NextRequest) {
username: user.username, username: user.username,
avatar: user.avatar, avatar: user.avatar,
}); });
} });
+9 -18
View File
@@ -1,22 +1,16 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { errorResponse, validateMembership } from "@/lib/blindbox"; import { validateMembership } from "@/lib/blindbox";
import { apiHandler, ApiError } from "@/lib/api";
export async function POST(req: NextRequest) { export const POST = apiHandler(async (req) => {
try {
const { roomId, userId } = await req.json(); const { roomId, userId } = await req.json();
if (!userId || typeof userId !== "string") { if (!userId || typeof userId !== "string") throw new ApiError("请先登录", 401);
return errorResponse("请先登录", 401); if (!roomId || typeof roomId !== "string") throw new ApiError("roomId 不能为空");
}
if (!roomId || typeof roomId !== "string") {
return errorResponse("roomId 不能为空", 400);
}
const isMember = await validateMembership(roomId, userId); const isMember = await validateMembership(roomId, userId);
if (!isMember) { if (!isMember) throw new ApiError("你不是这个房间的成员", 403);
return errorResponse("你不是这个房间的成员", 403);
}
const pool = await prisma.blindBoxIdea.findMany({ const pool = await prisma.blindBoxIdea.findMany({
where: { roomId, status: "in_pool" }, where: { roomId, status: "in_pool" },
@@ -24,7 +18,7 @@ export async function POST(req: NextRequest) {
}); });
if (pool.length === 0) { if (pool.length === 0) {
return errorResponse("盒子是空的,先往里面塞点想法吧!", 404); throw new ApiError("盒子是空的,先往里面塞点想法吧!", 404);
} }
const picked = pool[Math.floor(Math.random() * pool.length)]; const picked = pool[Math.floor(Math.random() * pool.length)];
@@ -45,7 +39,4 @@ export async function POST(req: NextRequest) {
submitter: idea.user, submitter: idea.user,
drawnBy: idea.drawnBy, drawnBy: idea.drawnBy,
}); });
} catch { });
return errorResponse("抽取失败", 500);
}
}
+11 -26
View File
@@ -1,18 +1,13 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { errorResponse, getRoomByCode } from "@/lib/blindbox"; import { getRoomByCode } from "@/lib/blindbox";
import { apiHandler, ApiError } from "@/lib/api";
export async function GET( export const GET = apiHandler(async (_req, { params }) => {
_req: NextRequest,
{ params }: { params: Promise<{ code: string }> },
) {
try {
const { code } = await params; const { code } = await params;
const room = await getRoomByCode(code.toUpperCase()); const room = await getRoomByCode(code.toUpperCase());
if (!room) { if (!room) throw new ApiError("房间不存在", 404);
return errorResponse("房间不存在", 404);
}
return NextResponse.json({ return NextResponse.json({
id: room.id, id: room.id,
@@ -25,25 +20,18 @@ export async function GET(
joinedAt: m.joinedAt, joinedAt: m.joinedAt,
})), })),
}); });
} catch { });
return errorResponse("获取房间信息失败", 500);
}
}
export async function DELETE( export const DELETE = apiHandler(async (req, { params }) => {
req: NextRequest,
{ params }: { params: Promise<{ code: string }> },
) {
try {
const { code } = await params; const { code } = await params;
const { userId } = await req.json(); const { userId } = await req.json();
if (!userId) return errorResponse("缺少用户 ID", 400); if (!userId) throw new ApiError("缺少用户 ID");
const room = await prisma.blindBoxRoom.findUnique({ const room = await prisma.blindBoxRoom.findUnique({
where: { code: code.toUpperCase() }, where: { code: code.toUpperCase() },
}); });
if (!room) return errorResponse("房间不存在", 404); if (!room) throw new ApiError("房间不存在", 404);
if (room.creatorId === userId) { if (room.creatorId === userId) {
await prisma.blindBoxRoom.delete({ where: { id: room.id } }); await prisma.blindBoxRoom.delete({ where: { id: room.id } });
@@ -53,11 +41,8 @@ export async function DELETE(
const membership = await prisma.blindBoxMember.findUnique({ const membership = await prisma.blindBoxMember.findUnique({
where: { roomId_userId: { roomId: room.id, userId } }, where: { roomId_userId: { roomId: room.id, userId } },
}); });
if (!membership) return errorResponse("你不是该房间成员", 403); if (!membership) throw new ApiError("你不是该房间成员", 403);
await prisma.blindBoxMember.delete({ where: { id: membership.id } }); await prisma.blindBoxMember.delete({ where: { id: membership.id } });
return NextResponse.json({ action: "left" }); return NextResponse.json({ action: "left" });
} catch { });
return errorResponse("操作失败", 500);
}
}
+17 -19
View File
@@ -1,38 +1,36 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { errorResponse } from "@/lib/blindbox"; import { apiHandler, ApiError } from "@/lib/api";
export async function POST(req: NextRequest) { export const POST = apiHandler(async (req) => {
try {
const { userId, code } = await req.json(); const { userId, code } = await req.json();
if (!userId || typeof userId !== "string") { if (!userId || typeof userId !== "string") throw new ApiError("请先登录", 401);
return errorResponse("请先登录", 401); if (!code || typeof code !== "string") throw new ApiError("请输入房间号");
}
if (!code || typeof code !== "string") {
return errorResponse("请输入房间号", 400);
}
const room = await prisma.blindBoxRoom.findUnique({ const room = await prisma.blindBoxRoom.findUnique({
where: { code: code.trim().toUpperCase() }, where: { code: code.trim().toUpperCase() },
}); });
if (!room) { if (!room) throw new ApiError("房间不存在,请检查房间号", 404);
return errorResponse("房间不存在,请检查房间号", 404);
}
const existing = await prisma.blindBoxMember.findUnique({ const existing = await prisma.blindBoxMember.findUnique({
where: { roomId_userId: { roomId: room.id, userId } }, where: { roomId_userId: { roomId: room.id, userId } },
}); });
if (existing) { if (existing) {
return NextResponse.json({ id: room.id, code: room.code, name: room.name, alreadyMember: true }); return NextResponse.json({
id: room.id,
code: room.code,
name: room.name,
alreadyMember: true,
});
} }
await prisma.blindBoxMember.create({ await prisma.blindBoxMember.create({
data: { roomId: room.id, userId }, data: { roomId: room.id, userId },
}); });
return NextResponse.json({ id: room.id, code: room.code, name: room.name }, { status: 201 }); return NextResponse.json(
} catch { { id: room.id, code: room.code, name: room.name },
return errorResponse("加入房间失败", 500); { status: 201 },
} );
} });
+13 -21
View File
@@ -1,24 +1,18 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { errorResponse, generateUniqueRoomCode } from "@/lib/blindbox"; import { generateUniqueRoomCode } from "@/lib/blindbox";
import { apiHandler, ApiError } from "@/lib/api";
export async function POST(req: NextRequest) { export const POST = apiHandler(async (req) => {
try {
const { userId, name } = await req.json(); const { userId, name } = await req.json();
if (!userId || typeof userId !== "string") { if (!userId || typeof userId !== "string") throw new ApiError("请先登录", 401);
return errorResponse("请先登录", 401);
}
const roomName = (name || "").trim() || "我们的周末"; const roomName = (name || "").trim() || "我们的周末";
if (roomName.length > 30) { if (roomName.length > 30) throw new ApiError("房间名不能超过 30 个字");
return errorResponse("房间名不能超过 30 个字", 400);
}
const user = await prisma.user.findUnique({ where: { id: userId } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) throw new ApiError("用户不存在", 404);
return errorResponse("用户不存在", 404);
}
const code = await generateUniqueRoomCode(); const code = await generateUniqueRoomCode();
@@ -27,14 +21,12 @@ export async function POST(req: NextRequest) {
code, code,
name: roomName, name: roomName,
creatorId: userId, creatorId: userId,
members: { members: { create: { userId } },
create: { userId },
},
}, },
}); });
return NextResponse.json({ id: room.id, code: room.code, name: room.name }, { status: 201 }); return NextResponse.json(
} catch { { id: room.id, code: room.code, name: room.name },
return errorResponse("创建房间失败", 500); { status: 201 },
} );
} });
+6 -16
View File
@@ -1,15 +1,11 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { errorResponse } from "@/lib/blindbox"; import { apiHandler, ApiError } from "@/lib/api";
export async function GET(req: NextRequest) { export const GET = apiHandler(async (req) => {
const userId = req.nextUrl.searchParams.get("userId"); const userId = req.nextUrl.searchParams.get("userId");
if (!userId) throw new ApiError("请先登录", 401);
if (!userId) {
return errorResponse("请先登录", 401);
}
try {
const memberships = await prisma.blindBoxMember.findMany({ const memberships = await prisma.blindBoxMember.findMany({
where: { userId }, where: { userId },
include: { include: {
@@ -21,10 +17,7 @@ export async function GET(req: NextRequest) {
take: 5, take: 5,
}, },
_count: { _count: {
select: { select: { ideas: true, members: true },
ideas: true,
members: true,
},
}, },
ideas: { ideas: {
where: { status: "drawn" }, where: { status: "drawn" },
@@ -63,7 +56,4 @@ export async function GET(req: NextRequest) {
} }
return NextResponse.json({ rooms }); return NextResponse.json({ rooms });
} catch { });
return errorResponse("获取房间列表失败", 500);
}
}
+32 -67
View File
@@ -1,60 +1,38 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { errorResponse, validateMembership } from "@/lib/blindbox"; import { validateMembership } from "@/lib/blindbox";
import { apiHandler, ApiError } from "@/lib/api";
export async function POST(req: NextRequest) { export const POST = apiHandler(async (req) => {
try {
const { roomId, userId, content } = await req.json(); const { roomId, userId, content } = await req.json();
if (!userId || typeof userId !== "string") { if (!userId || typeof userId !== "string") throw new ApiError("请先登录", 401);
return errorResponse("请先登录", 401); if (!roomId || typeof roomId !== "string") throw new ApiError("roomId 不能为空");
}
if (!roomId || typeof roomId !== "string") {
return errorResponse("roomId 不能为空", 400);
}
if (!content || typeof content !== "string" || content.trim().length === 0) { if (!content || typeof content !== "string" || content.trim().length === 0) {
return errorResponse("内容不能为空", 400); throw new ApiError("内容不能为空");
}
if (content.trim().length > 200) {
return errorResponse("内容不能超过 200 字", 400);
} }
if (content.trim().length > 200) throw new ApiError("内容不能超过 200 字");
const isMember = await validateMembership(roomId, userId); const isMember = await validateMembership(roomId, userId);
if (!isMember) { if (!isMember) throw new ApiError("你不是这个房间的成员", 403);
return errorResponse("你不是这个房间的成员", 403);
}
const idea = await prisma.blindBoxIdea.create({ const idea = await prisma.blindBoxIdea.create({
data: { data: { roomId, userId, content: content.trim() },
roomId,
userId,
content: content.trim(),
},
}); });
return NextResponse.json({ id: idea.id }, { status: 201 }); return NextResponse.json({ id: idea.id }, { status: 201 });
} catch { });
return errorResponse("提交失败", 500);
}
}
export async function GET(req: NextRequest) { export const GET = apiHandler(async (req) => {
const roomId = req.nextUrl.searchParams.get("roomId"); const roomId = req.nextUrl.searchParams.get("roomId");
const userId = req.nextUrl.searchParams.get("userId"); const userId = req.nextUrl.searchParams.get("userId");
if (!roomId) { if (!roomId) throw new ApiError("缺少 roomId");
return errorResponse("缺少 roomId", 400); if (!userId) throw new ApiError("请先登录", 401);
}
if (!userId) {
return errorResponse("请先登录", 401);
}
const isMember = await validateMembership(roomId, userId); const isMember = await validateMembership(roomId, userId);
if (!isMember) { if (!isMember) throw new ApiError("你不是这个房间的成员", 403);
return errorResponse("你不是这个房间的成员", 403);
}
try {
const [poolCount, myIdeas, drawn] = await Promise.all([ const [poolCount, myIdeas, drawn] = await Promise.all([
prisma.blindBoxIdea.count({ prisma.blindBoxIdea.count({
where: { roomId, status: "in_pool" }, where: { roomId, status: "in_pool" },
@@ -75,28 +53,22 @@ export async function GET(req: NextRequest) {
]); ]);
return NextResponse.json({ poolCount, myIdeas, drawn }); return NextResponse.json({ poolCount, myIdeas, drawn });
} catch { });
return errorResponse("查询失败", 500);
}
}
export async function PUT(req: NextRequest) { export const PUT = apiHandler(async (req) => {
try {
const { ideaId, userId, content } = await req.json(); const { ideaId, userId, content } = await req.json();
if (!userId) return errorResponse("请先登录", 401); if (!userId) throw new ApiError("请先登录", 401);
if (!ideaId) return errorResponse("缺少 ideaId", 400); if (!ideaId) throw new ApiError("缺少 ideaId");
if (!content || typeof content !== "string" || content.trim().length === 0) { if (!content || typeof content !== "string" || content.trim().length === 0) {
return errorResponse("内容不能为空", 400); throw new ApiError("内容不能为空");
}
if (content.trim().length > 200) {
return errorResponse("内容不能超过 200 字", 400);
} }
if (content.trim().length > 200) throw new ApiError("内容不能超过 200 字");
const idea = await prisma.blindBoxIdea.findUnique({ where: { id: ideaId } }); const idea = await prisma.blindBoxIdea.findUnique({ where: { id: ideaId } });
if (!idea) return errorResponse("想法不存在", 404); if (!idea) throw new ApiError("想法不存在", 404);
if (idea.userId !== userId) return errorResponse("只能编辑自己的想法", 403); if (idea.userId !== userId) throw new ApiError("只能编辑自己的想法", 403);
if (idea.status !== "in_pool") return errorResponse("已抽中的想法不能编辑", 400); if (idea.status !== "in_pool") throw new ApiError("已抽中的想法不能编辑");
const updated = await prisma.blindBoxIdea.update({ const updated = await prisma.blindBoxIdea.update({
where: { id: ideaId }, where: { id: ideaId },
@@ -104,27 +76,20 @@ export async function PUT(req: NextRequest) {
}); });
return NextResponse.json({ id: updated.id, content: updated.content }); return NextResponse.json({ id: updated.id, content: updated.content });
} catch { });
return errorResponse("编辑失败", 500);
}
}
export async function DELETE(req: NextRequest) { export const DELETE = apiHandler(async (req) => {
try {
const { ideaId, userId } = await req.json(); const { ideaId, userId } = await req.json();
if (!userId) return errorResponse("请先登录", 401); if (!userId) throw new ApiError("请先登录", 401);
if (!ideaId) return errorResponse("缺少 ideaId", 400); if (!ideaId) throw new ApiError("缺少 ideaId");
const idea = await prisma.blindBoxIdea.findUnique({ where: { id: ideaId } }); const idea = await prisma.blindBoxIdea.findUnique({ where: { id: ideaId } });
if (!idea) return errorResponse("想法不存在", 404); if (!idea) throw new ApiError("想法不存在", 404);
if (idea.userId !== userId) return errorResponse("只能删除自己的想法", 403); if (idea.userId !== userId) throw new ApiError("只能删除自己的想法", 403);
if (idea.status !== "in_pool") return errorResponse("已抽中的想法不能删除", 400); if (idea.status !== "in_pool") throw new ApiError("已抽中的想法不能删除");
await prisma.blindBoxIdea.delete({ where: { id: ideaId } }); await prisma.blindBoxIdea.delete({ where: { id: ideaId } });
return NextResponse.json({ deleted: true }); return NextResponse.json({ deleted: true });
} catch { });
return errorResponse("删除失败", 500);
}
}
+7 -22
View File
@@ -1,26 +1,15 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api";
export async function GET(req: Request) { export const GET = apiHandler(async (req) => {
const { searchParams } = new URL(req.url); const lat = req.nextUrl.searchParams.get("lat");
const lat = searchParams.get("lat"); const lng = req.nextUrl.searchParams.get("lng");
const lng = searchParams.get("lng");
if (!lat || !lng) { if (!lat || !lng) throw new ApiError("lat and lng are required");
return NextResponse.json(
{ error: "lat and lng are required" },
{ status: 400 },
);
}
const apiKey = process.env.AMAP_API_KEY; const apiKey = process.env.AMAP_API_KEY;
if (!apiKey) { if (!apiKey) throw new ApiError("AMAP_API_KEY not configured", 500);
return NextResponse.json(
{ error: "AMAP_API_KEY not configured" },
{ status: 500 },
);
}
try {
const url = new URL("https://restapi.amap.com/v3/geocode/regeo"); const url = new URL("https://restapi.amap.com/v3/geocode/regeo");
url.searchParams.set("key", apiKey); url.searchParams.set("key", apiKey);
url.searchParams.set("location", `${lng},${lat}`); url.searchParams.set("location", `${lng},${lat}`);
@@ -47,8 +36,4 @@ export async function GET(req: Request) {
name: name || data.regeocode.formatted_address || null, name: name || data.regeocode.formatted_address || null,
formatted: data.regeocode.formatted_address || null, formatted: data.regeocode.formatted_address || null,
}); });
} catch (e) { });
console.error("Regeo error:", e);
return NextResponse.json({ name: null });
}
}
+7 -22
View File
@@ -1,22 +1,13 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api";
export async function GET(req: Request) { export const GET = apiHandler(async (req) => {
const { searchParams } = new URL(req.url); const keywords = req.nextUrl.searchParams.get("keywords")?.trim();
const keywords = searchParams.get("keywords")?.trim(); if (!keywords) return NextResponse.json([]);
if (!keywords) {
return NextResponse.json([]);
}
const apiKey = process.env.AMAP_API_KEY; const apiKey = process.env.AMAP_API_KEY;
if (!apiKey) { if (!apiKey) throw new ApiError("AMAP_API_KEY not configured", 500);
return NextResponse.json(
{ error: "AMAP_API_KEY not configured" },
{ status: 500 },
);
}
try {
const url = new URL("https://restapi.amap.com/v3/assistant/inputtips"); const url = new URL("https://restapi.amap.com/v3/assistant/inputtips");
url.searchParams.set("key", apiKey); url.searchParams.set("key", apiKey);
url.searchParams.set("keywords", keywords); url.searchParams.set("keywords", keywords);
@@ -25,9 +16,7 @@ export async function GET(req: Request) {
const res = await fetch(url.toString()); const res = await fetch(url.toString());
const data = await res.json(); const data = await res.json();
if (data.status !== "1" || !data.tips) { if (data.status !== "1" || !data.tips) return NextResponse.json([]);
return NextResponse.json([]);
}
const suggestions = data.tips const suggestions = data.tips
.filter((t: { location?: string }) => t.location && t.location !== "") .filter((t: { location?: string }) => t.location && t.location !== "")
@@ -45,8 +34,4 @@ export async function GET(req: Request) {
}); });
return NextResponse.json(suggestions); return NextResponse.json(suggestions);
} catch (e) { });
console.error("Location suggest error:", e);
return NextResponse.json([]);
}
}
+7 -33
View File
@@ -1,25 +1,20 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/store";
import { notify } from "@/lib/roomEvents"; import { notify } from "@/lib/roomEvents";
import { apiHandler, ApiError } from "@/lib/api";
export async function POST( export const POST = apiHandler(async (req, { params }) => {
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params; const { id } = await params;
try {
const { userId } = await req.json(); const { userId } = await req.json();
if (!userId) {
return NextResponse.json({ error: "userId required" }, { status: 400 }); if (!userId) throw new ApiError("userId required");
}
const updated = await atomicUpdateRoom(id, (data) => { const updated = await atomicUpdateRoom(id, (data) => {
if (data.kickedUsers.includes(userId)) { if (data.kickedUsers.includes(userId)) {
throw new Error("KICKED"); throw new ApiError("你已被移出该房间", 403);
} }
if (data.locked && !data.users.includes(userId)) { if (data.locked && !data.users.includes(userId)) {
throw new Error("LOCKED"); throw new ApiError("房间已锁定,无法加入", 403);
} }
if (!data.users.includes(userId)) { if (!data.users.includes(userId)) {
data.users.push(userId); data.users.push(userId);
@@ -40,25 +35,4 @@ export async function POST(
roomId: id, roomId: id,
userCount: updated.users.length, userCount: updated.users.length,
}); });
} catch (e) { });
if (e instanceof Error) {
if (e.message === "LOCKED") {
return NextResponse.json(
{ error: "房间已锁定,无法加入" },
{ status: 403 },
);
}
if (e.message === "KICKED") {
return NextResponse.json(
{ error: "你已被移出该房间" },
{ status: 403 },
);
}
}
console.error("Failed to join room:", e);
return NextResponse.json(
{ error: "加入房间失败" },
{ status: 500 },
);
}
}
+8 -41
View File
@@ -1,25 +1,19 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/store";
import { notify } from "@/lib/roomEvents"; import { notify } from "@/lib/roomEvents";
import { apiHandler, ApiError } from "@/lib/api";
export async function POST( export const POST = apiHandler(async (req, { params }) => {
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params; const { id } = await params;
try {
const { userId, action, targetUserId } = await req.json(); const { userId, action, targetUserId } = await req.json();
if (!userId || !action) { if (!userId || !action) {
return NextResponse.json( throw new ApiError("userId and action required");
{ error: "userId and action required" },
{ status: 400 },
);
} }
const updated = await atomicUpdateRoom(id, (data) => { const updated = await atomicUpdateRoom(id, (data) => {
if (data.creatorId !== userId) { if (data.creatorId !== userId) {
throw new Error("FORBIDDEN"); throw new ApiError("只有房主可以执行此操作", 403);
} }
switch (action) { switch (action) {
@@ -33,7 +27,7 @@ export async function POST(
case "kick": case "kick":
if (!targetUserId || targetUserId === userId) { if (!targetUserId || targetUserId === userId) {
throw new Error("INVALID_TARGET"); throw new ApiError("无效的操作对象");
} }
data.users = data.users.filter((u) => u !== targetUserId); data.users = data.users.filter((u) => u !== targetUserId);
if (!data.kickedUsers.includes(targetUserId)) { if (!data.kickedUsers.includes(targetUserId)) {
@@ -60,7 +54,7 @@ export async function POST(
break; break;
default: default:
throw new Error("UNKNOWN_ACTION"); throw new ApiError("未知操作");
} }
return data; return data;
@@ -76,31 +70,4 @@ export async function POST(
notify(id); notify(id);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (e) { });
if (e instanceof Error) {
if (e.message === "FORBIDDEN") {
return NextResponse.json(
{ error: "只有房主可以执行此操作" },
{ status: 403 },
);
}
if (e.message === "INVALID_TARGET") {
return NextResponse.json(
{ error: "无效的操作对象" },
{ status: 400 },
);
}
if (e.message === "UNKNOWN_ACTION") {
return NextResponse.json(
{ error: "未知操作" },
{ status: 400 },
);
}
}
console.error("Failed to manage room:", e);
return NextResponse.json(
{ error: "操作失败" },
{ status: 500 },
);
}
}
+3 -13
View File
@@ -1,11 +1,9 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/store";
import { notify } from "@/lib/roomEvents"; import { notify } from "@/lib/roomEvents";
import { apiHandler } from "@/lib/api";
export async function POST( export const POST = apiHandler(async (req, { params }) => {
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params; const { id } = await params;
let restaurantIds: string[] | undefined; let restaurantIds: string[] | undefined;
@@ -18,7 +16,6 @@ export async function POST(
// No body or invalid JSON — plain reset // No body or invalid JSON — plain reset
} }
try {
const updated = await atomicUpdateRoom(id, (data) => { const updated = await atomicUpdateRoom(id, (data) => {
if (restaurantIds && restaurantIds.length > 0) { if (restaurantIds && restaurantIds.length > 0) {
const idSet = new Set(restaurantIds); const idSet = new Set(restaurantIds);
@@ -40,11 +37,4 @@ export async function POST(
notify(id); notify(id);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (e) { });
console.error("Failed to reset room:", e);
return NextResponse.json(
{ error: "重置失败" },
{ status: 500 },
);
}
}
+3 -13
View File
@@ -1,13 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { buildRoomStatus } from "@/lib/buildRoomStatus"; import { buildRoomStatus } from "@/lib/buildRoomStatus";
import { apiHandler } from "@/lib/api";
export async function GET( export const GET = apiHandler(async (_req, { params }) => {
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params; const { id } = await params;
try {
const status = await buildRoomStatus(id); const status = await buildRoomStatus(id);
if (!status) { if (!status) {
@@ -18,11 +15,4 @@ export async function GET(
} }
return NextResponse.json(status); return NextResponse.json(status);
} catch (e) { });
console.error("Failed to get room:", e);
return NextResponse.json(
{ error: "获取房间信息失败" },
{ status: 500 },
);
}
}
+4 -18
View File
@@ -1,21 +1,14 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/store";
import { notify } from "@/lib/roomEvents"; import { notify } from "@/lib/roomEvents";
import { apiHandler, ApiError } from "@/lib/api";
export async function POST( export const POST = apiHandler(async (req, { params }) => {
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params; const { id } = await params;
try {
const { userId, restaurantId, action } = await req.json(); const { userId, restaurantId, action } = await req.json();
if (!userId || restaurantId == null || !action) { if (!userId || restaurantId == null || !action) {
return NextResponse.json( throw new ApiError("userId, restaurantId, and action are required");
{ error: "userId, restaurantId, and action are required" },
{ status: 400 },
);
} }
const rid = String(restaurantId); const rid = String(restaurantId);
@@ -59,11 +52,4 @@ export async function POST(
match: updated.match, match: updated.match,
likeCount: updated.likes[rid]?.length ?? 0, likeCount: updated.likes[rid]?.length ?? 0,
}); });
} catch (e) { });
console.error("Failed to process swipe:", e);
return NextResponse.json(
{ error: "操作失败" },
{ status: 500 },
);
}
}
+4 -18
View File
@@ -1,21 +1,14 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/store";
import { notify } from "@/lib/roomEvents"; import { notify } from "@/lib/roomEvents";
import { apiHandler, ApiError } from "@/lib/api";
export async function POST( export const POST = apiHandler(async (req, { params }) => {
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params; const { id } = await params;
try {
const { userId, restaurantId } = await req.json(); const { userId, restaurantId } = await req.json();
if (!userId || restaurantId == null) { if (!userId || restaurantId == null) {
return NextResponse.json( throw new ApiError("userId and restaurantId are required");
{ error: "userId and restaurantId are required" },
{ status: 400 },
);
} }
const rid = String(restaurantId); const rid = String(restaurantId);
@@ -50,11 +43,4 @@ export async function POST(
notify(id); notify(id);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (e) { });
console.error("Failed to undo swipe:", e);
return NextResponse.json(
{ error: "撤回失败" },
{ status: 500 },
);
}
}
+5 -19
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { createRoom } from "@/lib/store"; import { createRoom } from "@/lib/store";
import { Restaurant, SceneType } from "@/types"; import { Restaurant, SceneType } from "@/types";
import { getSceneConfig } from "@/lib/sceneConfig"; import { getSceneConfig } from "@/lib/sceneConfig";
import { apiHandler, ApiError } from "@/lib/api";
interface AmapPoiV5 { interface AmapPoiV5 {
id: string; id: string;
@@ -101,8 +102,7 @@ function filterByPrice(
}); });
} }
export async function POST(req: Request) { export const POST = apiHandler(async (req) => {
try {
const body = await req.json(); const body = await req.json();
const { const {
lat, lat,
@@ -117,19 +117,12 @@ export async function POST(req: Request) {
const sceneConfig = getSceneConfig(scene === "drink" ? "drink" : "eat"); const sceneConfig = getSceneConfig(scene === "drink" ? "drink" : "eat");
if (!lat || !lng) { if (!lat || !lng) {
return NextResponse.json( throw new ApiError("无法获取位置信息,请允许定位权限后重试");
{ error: "无法获取位置信息,请允许定位权限后重试" },
{ status: 400 },
);
} }
const apiKey = process.env.AMAP_API_KEY; const apiKey = process.env.AMAP_API_KEY;
if (!apiKey) { if (!apiKey) {
console.error("AMAP_API_KEY not configured"); throw new ApiError("服务配置异常,请稍后重试", 500);
return NextResponse.json(
{ error: "服务配置异常,请稍后重试" },
{ status: 500 },
);
} }
const url = new URL("https://restapi.amap.com/v5/place/around"); const url = new URL("https://restapi.amap.com/v5/place/around");
@@ -168,11 +161,4 @@ export async function POST(req: Request) {
const roomId = await createRoom(restaurants, userId, sceneConfig.key); const roomId = await createRoom(restaurants, userId, sceneConfig.key);
return NextResponse.json({ roomId, restaurants }); return NextResponse.json({ roomId, restaurants });
} catch (e) { });
console.error("Failed to create room:", e);
return NextResponse.json(
{ error: "搜索失败,请检查网络后重试" },
{ status: 500 },
);
}
}
+13 -22
View File
@@ -1,11 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { apiHandler, ApiError } from "@/lib/api";
export async function GET(req: NextRequest) { export const GET = apiHandler(async (req) => {
const userId = req.nextUrl.searchParams.get("userId"); const userId = req.nextUrl.searchParams.get("userId");
if (!userId) { if (!userId) return NextResponse.json([]);
return NextResponse.json([]);
}
const favorites = await prisma.favorite.findMany({ const favorites = await prisma.favorite.findMany({
where: { userId }, where: { userId },
@@ -20,19 +19,15 @@ export async function GET(req: NextRequest) {
createdAt: f.createdAt.toISOString(), createdAt: f.createdAt.toISOString(),
})), })),
); );
} });
export async function POST(req: NextRequest) { export const POST = apiHandler(async (req) => {
const { userId, restaurant } = await req.json(); const { userId, restaurant } = await req.json();
if (!userId || !restaurant) { if (!userId || !restaurant) throw new ApiError("缺少必要字段");
return NextResponse.json({ error: "缺少必要字段" }, { status: 400 });
}
const user = await prisma.user.findUnique({ where: { id: userId } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) throw new ApiError("请先设置个人资料", 404);
return NextResponse.json({ error: "请先设置个人资料" }, { status: 404 });
}
const existing = await prisma.favorite.findFirst({ const existing = await prisma.favorite.findFirst({
where: { where: {
@@ -53,20 +48,16 @@ export async function POST(req: NextRequest) {
}); });
return NextResponse.json({ id: fav.id }); return NextResponse.json({ id: fav.id });
} });
export async function DELETE(req: NextRequest) { export const DELETE = apiHandler(async (req) => {
const { userId, favoriteId } = await req.json(); const { userId, favoriteId } = await req.json();
if (!userId || !favoriteId) { if (!userId || !favoriteId) throw new ApiError("缺少必要字段");
return NextResponse.json({ error: "缺少必要字段" }, { status: 400 });
}
const fav = await prisma.favorite.findUnique({ where: { id: favoriteId } }); const fav = await prisma.favorite.findUnique({ where: { id: favoriteId } });
if (!fav || fav.userId !== userId) { if (!fav || fav.userId !== userId) throw new ApiError("收藏不存在", 404);
return NextResponse.json({ error: "收藏不存在" }, { status: 404 });
}
await prisma.favorite.delete({ where: { id: favoriteId } }); await prisma.favorite.delete({ where: { id: favoriteId } });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} });
+9 -12
View File
@@ -1,13 +1,12 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { apiHandler, ApiError } from "@/lib/api";
const MAX_HISTORY = 50; const MAX_HISTORY = 50;
export async function GET(req: NextRequest) { export const GET = apiHandler(async (req) => {
const userId = req.nextUrl.searchParams.get("userId"); const userId = req.nextUrl.searchParams.get("userId");
if (!userId) { if (!userId) return NextResponse.json([]);
return NextResponse.json([]);
}
const decisions = await prisma.decision.findMany({ const decisions = await prisma.decision.findMany({
where: { userId }, where: { userId },
@@ -26,20 +25,18 @@ export async function GET(req: NextRequest) {
createdAt: d.createdAt.toISOString(), createdAt: d.createdAt.toISOString(),
})), })),
); );
} });
export async function POST(req: NextRequest) { export const POST = apiHandler(async (req) => {
const { userId, roomId, restaurant, matchType, participants } = const { userId, roomId, restaurant, matchType, participants } =
await req.json(); await req.json();
if (!userId || !roomId || !restaurant || !matchType) { if (!userId || !roomId || !restaurant || !matchType) {
return NextResponse.json({ error: "缺少必要字段" }, { status: 400 }); throw new ApiError("缺少必要字段");
} }
const user = await prisma.user.findUnique({ where: { id: userId } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) throw new ApiError("用户未注册", 404);
return NextResponse.json({ error: "用户未注册" }, { status: 404 });
}
const existing = await prisma.decision.findFirst({ const existing = await prisma.decision.findFirst({
where: { userId, roomId }, where: { userId, roomId },
@@ -73,4 +70,4 @@ export async function POST(req: NextRequest) {
} }
return NextResponse.json({ id: decision.id }); return NextResponse.json({ id: decision.id });
} });
+16 -31
View File
@@ -1,17 +1,14 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { apiHandler, ApiError } from "@/lib/api";
export async function GET(req: NextRequest) { export const GET = apiHandler(async (req) => {
const userId = req.nextUrl.searchParams.get("id"); const userId = req.nextUrl.searchParams.get("id");
if (!userId) { if (!userId) return NextResponse.json(null);
return NextResponse.json(null);
}
const user = await prisma.user.findUnique({ where: { id: userId } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) return NextResponse.json(null);
return NextResponse.json(null);
}
const decisionCount = await prisma.decision.count({ where: { userId } }); const decisionCount = await prisma.decision.count({ where: { userId } });
@@ -24,48 +21,36 @@ export async function GET(req: NextRequest) {
createdAt: user.createdAt.toISOString(), createdAt: user.createdAt.toISOString(),
decisionCount, decisionCount,
}); });
} });
export async function PUT(req: NextRequest) { export const PUT = apiHandler(async (req) => {
const body = await req.json(); const body = await req.json();
const { userId } = body; const { userId } = body;
if (!userId) { if (!userId) throw new ApiError("缺少用户 ID");
return NextResponse.json({ error: "缺少用户 ID" }, { status: 400 });
}
const existing = await prisma.user.findUnique({ where: { id: userId } }); const existing = await prisma.user.findUnique({ where: { id: userId } });
if (!existing) { if (!existing) throw new ApiError("用户不存在", 404);
return NextResponse.json({ error: "用户不存在" }, { status: 404 });
}
const updateData: Record<string, unknown> = {}; const updateData: Record<string, unknown> = {};
if (body.username !== undefined) { if (body.username !== undefined) {
const trimmed = body.username.trim(); const trimmed = body.username.trim();
if (trimmed.length < 2 || trimmed.length > 16) { if (trimmed.length < 2 || trimmed.length > 16) {
return NextResponse.json({ error: "用户名需要 2-16 个字符" }, { status: 400 }); throw new ApiError("用户名需要 2-16 个字符");
} }
if (trimmed !== existing.username) { if (trimmed !== existing.username) {
const taken = await prisma.user.findUnique({ where: { username: trimmed } }); const taken = await prisma.user.findUnique({ where: { username: trimmed } });
if (taken) { if (taken) throw new ApiError("用户名已被占用", 409);
return NextResponse.json({ error: "用户名已被占用" }, { status: 409 });
}
} }
updateData.username = trimmed; updateData.username = trimmed;
} }
if (body.newPassword !== undefined) { if (body.newPassword !== undefined) {
if (!body.currentPassword) { if (!body.currentPassword) throw new ApiError("请输入当前密码");
return NextResponse.json({ error: "请输入当前密码" }, { status: 400 });
}
const valid = await bcrypt.compare(body.currentPassword, existing.passwordHash); const valid = await bcrypt.compare(body.currentPassword, existing.passwordHash);
if (!valid) { if (!valid) throw new ApiError("当前密码错误", 403);
return NextResponse.json({ error: "当前密码错误" }, { status: 403 }); if (body.newPassword.length < 6) throw new ApiError("新密码至少 6 个字符");
}
if (body.newPassword.length < 6) {
return NextResponse.json({ error: "新密码至少 6 个字符" }, { status: 400 });
}
updateData.passwordHash = await bcrypt.hash(body.newPassword, 10); updateData.passwordHash = await bcrypt.hash(body.newPassword, 10);
} }
@@ -75,7 +60,7 @@ export async function PUT(req: NextRequest) {
if (body.email !== undefined) { if (body.email !== undefined) {
if (body.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) { if (body.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
return NextResponse.json({ error: "邮箱格式不正确" }, { status: 400 }); throw new ApiError("邮箱格式不正确");
} }
updateData.email = body.email || null; updateData.email = body.email || null;
} }
@@ -96,4 +81,4 @@ export async function PUT(req: NextRequest) {
email: user.email, email: user.email,
preferences: JSON.parse(user.preferences), preferences: JSON.parse(user.preferences),
}); });
} });
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
export class ApiError extends Error {
constructor(
message: string,
public status: number = 400,
) {
super(message);
}
}
type RouteContext = { params: Promise<Record<string, string>> };
type RouteHandler = (
req: NextRequest,
ctx: RouteContext,
) => Promise<NextResponse>;
/**
* Wraps a Next.js route handler with unified error handling.
* - ApiError instances are converted to JSON responses with matching status codes
* - Unknown errors are logged and returned as 500
*/
export function apiHandler(handler: RouteHandler): RouteHandler {
return async (req, ctx) => {
try {
return await handler(req, ctx);
} catch (e) {
if (e instanceof ApiError) {
return NextResponse.json({ error: e.message }, { status: e.status });
}
console.error(`[API ${req.method} ${req.nextUrl.pathname}]`, e);
return NextResponse.json({ error: "操作失败" }, { status: 500 });
}
};
}
-5
View File
@@ -1,9 +1,4 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export function errorResponse(message: string, status: number) {
return NextResponse.json({ error: message }, { status });
}
export function generateRoomCode(): string { export function generateRoomCode(): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";