refactor: 提取 validation.ts 和 amap.ts,统一 API 路由校验逻辑
新增 validation.ts(用户名/密码/邮箱/内容/房间名/必填字段校验) 和 amap.ts(AMAP API key 校验),消除 7 个路由中的重复验证代码。
This commit is contained in:
@@ -2,17 +2,15 @@ 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";
|
import { apiHandler, ApiError } from "@/lib/api";
|
||||||
|
import { validateUsername, validatePassword } from "@/lib/validation";
|
||||||
|
|
||||||
export const POST = apiHandler(async (req) => {
|
export const POST = apiHandler(async (req) => {
|
||||||
const { username, password, avatar } = await req.json();
|
const { username, password, avatar } = await req.json();
|
||||||
|
|
||||||
if (!username || !password) throw new ApiError("用户名和密码为必填项");
|
if (!username || !password) throw new ApiError("用户名和密码为必填项");
|
||||||
|
|
||||||
const trimmedUsername = username.trim();
|
const trimmedUsername = validateUsername(username);
|
||||||
if (trimmedUsername.length < 2 || trimmedUsername.length > 16) {
|
validatePassword(password);
|
||||||
throw new ApiError("用户名需要 2-16 个字符");
|
|
||||||
}
|
|
||||||
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) throw new ApiError("用户名已被注册", 409);
|
if (existing) throw new ApiError("用户名已被注册", 409);
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { generateUniqueRoomCode } from "@/lib/blindbox";
|
import { generateUniqueRoomCode } from "@/lib/blindbox";
|
||||||
import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api";
|
import { apiHandler, requireUserId, requireUser } from "@/lib/api";
|
||||||
|
import { validateRoomName } from "@/lib/validation";
|
||||||
|
|
||||||
export const POST = apiHandler(async (req) => {
|
export const POST = apiHandler(async (req) => {
|
||||||
const { userId, name } = await req.json();
|
const { userId, name } = await req.json();
|
||||||
|
|
||||||
requireUserId(userId);
|
requireUserId(userId);
|
||||||
|
|
||||||
const roomName = (name || "").trim() || "我们的周末";
|
const roomName = validateRoomName(name);
|
||||||
if (roomName.length > 30) throw new ApiError("房间名不能超过 30 个字");
|
|
||||||
|
|
||||||
await requireUser(userId);
|
await requireUser(userId);
|
||||||
|
|
||||||
|
|||||||
@@ -2,31 +2,27 @@ import { NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireMembership } from "@/lib/blindbox";
|
import { requireMembership } from "@/lib/blindbox";
|
||||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||||
|
import { validateIdeaContent, requireString } from "@/lib/validation";
|
||||||
|
|
||||||
export const POST = apiHandler(async (req) => {
|
export const POST = apiHandler(async (req) => {
|
||||||
const { roomId, userId, content } = await req.json();
|
const { roomId, userId, content } = await req.json();
|
||||||
|
|
||||||
requireUserId(userId);
|
requireUserId(userId);
|
||||||
if (!roomId || typeof roomId !== "string") throw new ApiError("roomId 不能为空");
|
requireString(roomId, "roomId");
|
||||||
if (!content || typeof content !== "string" || content.trim().length === 0) {
|
const trimmedContent = validateIdeaContent(content);
|
||||||
throw new ApiError("内容不能为空");
|
|
||||||
}
|
|
||||||
if (content.trim().length > 200) throw new ApiError("内容不能超过 200 字");
|
|
||||||
|
|
||||||
await requireMembership(roomId, userId);
|
await requireMembership(roomId, userId);
|
||||||
|
|
||||||
const idea = await prisma.blindBoxIdea.create({
|
const idea = await prisma.blindBoxIdea.create({
|
||||||
data: { roomId, userId, content: content.trim() },
|
data: { roomId, userId, content: trimmedContent },
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ id: idea.id }, { status: 201 });
|
return NextResponse.json({ id: idea.id }, { status: 201 });
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GET = apiHandler(async (req) => {
|
export const GET = apiHandler(async (req) => {
|
||||||
const roomId = req.nextUrl.searchParams.get("roomId");
|
|
||||||
const userId = requireUserId(req.nextUrl.searchParams.get("userId"));
|
const userId = requireUserId(req.nextUrl.searchParams.get("userId"));
|
||||||
|
const roomId = requireString(req.nextUrl.searchParams.get("roomId"), "roomId");
|
||||||
if (!roomId) throw new ApiError("缺少 roomId");
|
|
||||||
|
|
||||||
await requireMembership(roomId, userId);
|
await requireMembership(roomId, userId);
|
||||||
|
|
||||||
@@ -56,11 +52,8 @@ export const PUT = apiHandler(async (req) => {
|
|||||||
const { ideaId, userId, content } = await req.json();
|
const { ideaId, userId, content } = await req.json();
|
||||||
|
|
||||||
requireUserId(userId);
|
requireUserId(userId);
|
||||||
if (!ideaId) throw new ApiError("缺少 ideaId");
|
requireString(ideaId, "ideaId");
|
||||||
if (!content || typeof content !== "string" || content.trim().length === 0) {
|
const trimmedContent = validateIdeaContent(content);
|
||||||
throw new ApiError("内容不能为空");
|
|
||||||
}
|
|
||||||
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) throw new ApiError("想法不存在", 404);
|
if (!idea) throw new ApiError("想法不存在", 404);
|
||||||
@@ -69,7 +62,7 @@ export const PUT = apiHandler(async (req) => {
|
|||||||
|
|
||||||
const updated = await prisma.blindBoxIdea.update({
|
const updated = await prisma.blindBoxIdea.update({
|
||||||
where: { id: ideaId },
|
where: { id: ideaId },
|
||||||
data: { content: content.trim() },
|
data: { content: trimmedContent },
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ id: updated.id, content: updated.content });
|
return NextResponse.json({ id: updated.id, content: updated.content });
|
||||||
@@ -79,7 +72,7 @@ export const DELETE = apiHandler(async (req) => {
|
|||||||
const { ideaId, userId } = await req.json();
|
const { ideaId, userId } = await req.json();
|
||||||
|
|
||||||
requireUserId(userId);
|
requireUserId(userId);
|
||||||
if (!ideaId) throw new ApiError("缺少 ideaId");
|
requireString(ideaId, "ideaId");
|
||||||
|
|
||||||
const idea = await prisma.blindBoxIdea.findUnique({ where: { id: ideaId } });
|
const idea = await prisma.blindBoxIdea.findUnique({ where: { id: ideaId } });
|
||||||
if (!idea) throw new ApiError("想法不存在", 404);
|
if (!idea) throw new ApiError("想法不存在", 404);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { apiHandler, ApiError } from "@/lib/api";
|
import { apiHandler, ApiError } from "@/lib/api";
|
||||||
|
import { requireAmapApiKey } from "@/lib/amap";
|
||||||
|
|
||||||
export const GET = apiHandler(async (req) => {
|
export const GET = apiHandler(async (req) => {
|
||||||
const lat = req.nextUrl.searchParams.get("lat");
|
const lat = req.nextUrl.searchParams.get("lat");
|
||||||
@@ -7,8 +8,7 @@ export const GET = apiHandler(async (req) => {
|
|||||||
|
|
||||||
if (!lat || !lng) throw new ApiError("lat and lng are required");
|
if (!lat || !lng) throw new ApiError("lat and lng are required");
|
||||||
|
|
||||||
const apiKey = process.env.AMAP_API_KEY;
|
const apiKey = requireAmapApiKey();
|
||||||
if (!apiKey) throw new ApiError("AMAP_API_KEY not configured", 500);
|
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { apiHandler, ApiError } from "@/lib/api";
|
import { apiHandler } from "@/lib/api";
|
||||||
|
import { requireAmapApiKey } from "@/lib/amap";
|
||||||
|
|
||||||
export const GET = apiHandler(async (req) => {
|
export const GET = apiHandler(async (req) => {
|
||||||
const keywords = req.nextUrl.searchParams.get("keywords")?.trim();
|
const keywords = req.nextUrl.searchParams.get("keywords")?.trim();
|
||||||
if (!keywords) return NextResponse.json([]);
|
if (!keywords) return NextResponse.json([]);
|
||||||
|
|
||||||
const apiKey = process.env.AMAP_API_KEY;
|
const apiKey = requireAmapApiKey();
|
||||||
if (!apiKey) throw new ApiError("AMAP_API_KEY not configured", 500);
|
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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";
|
import { apiHandler, ApiError } from "@/lib/api";
|
||||||
|
import { requireAmapApiKey } from "@/lib/amap";
|
||||||
|
|
||||||
interface AmapPoiV5 {
|
interface AmapPoiV5 {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -120,10 +121,7 @@ export const POST = apiHandler(async (req) => {
|
|||||||
throw new ApiError("无法获取位置信息,请允许定位权限后重试");
|
throw new ApiError("无法获取位置信息,请允许定位权限后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = process.env.AMAP_API_KEY;
|
const apiKey = requireAmapApiKey();
|
||||||
if (!apiKey) {
|
|
||||||
throw new ApiError("服务配置异常,请稍后重试", 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL("https://restapi.amap.com/v5/place/around");
|
const url = new URL("https://restapi.amap.com/v5/place/around");
|
||||||
url.searchParams.set("key", apiKey);
|
url.searchParams.set("key", apiKey);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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, requireUserId, requireUser } from "@/lib/api";
|
import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api";
|
||||||
|
import { validateUsername, validatePassword, validateEmail } from "@/lib/validation";
|
||||||
|
|
||||||
export const GET = apiHandler(async (req) => {
|
export const GET = apiHandler(async (req) => {
|
||||||
const userId = req.nextUrl.searchParams.get("id");
|
const userId = req.nextUrl.searchParams.get("id");
|
||||||
@@ -33,10 +34,7 @@ export const PUT = apiHandler(async (req) => {
|
|||||||
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 = validateUsername(body.username);
|
||||||
if (trimmed.length < 2 || trimmed.length > 16) {
|
|
||||||
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) throw new ApiError("用户名已被占用", 409);
|
if (taken) throw new ApiError("用户名已被占用", 409);
|
||||||
@@ -48,7 +46,7 @@ export const PUT = apiHandler(async (req) => {
|
|||||||
if (!body.currentPassword) throw new ApiError("请输入当前密码");
|
if (!body.currentPassword) throw new ApiError("请输入当前密码");
|
||||||
const valid = await bcrypt.compare(body.currentPassword, existing.passwordHash);
|
const valid = await bcrypt.compare(body.currentPassword, existing.passwordHash);
|
||||||
if (!valid) throw new ApiError("当前密码错误", 403);
|
if (!valid) throw new ApiError("当前密码错误", 403);
|
||||||
if (body.newPassword.length < 6) throw new ApiError("新密码至少 6 个字符");
|
validatePassword(body.newPassword, "新密码");
|
||||||
updateData.passwordHash = await bcrypt.hash(body.newPassword, 10);
|
updateData.passwordHash = await bcrypt.hash(body.newPassword, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +55,7 @@ export const PUT = apiHandler(async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (body.email !== undefined) {
|
if (body.email !== undefined) {
|
||||||
if (body.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
|
if (body.email) validateEmail(body.email);
|
||||||
throw new ApiError("邮箱格式不正确");
|
|
||||||
}
|
|
||||||
updateData.email = body.email || null;
|
updateData.email = body.email || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
export function requireAmapApiKey(): string {
|
||||||
|
const key = process.env.AMAP_API_KEY;
|
||||||
|
if (!key) throw new ApiError("服务配置异常,请稍后重试", 500);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
export function validateUsername(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed.length < 2 || trimmed.length > 16) {
|
||||||
|
throw new ApiError("用户名需要 2-16 个字符");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePassword(password: string, label = "密码"): void {
|
||||||
|
if (password.length < 6) {
|
||||||
|
throw new ApiError(`${label}至少 6 个字符`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEmail(email: string): void {
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
throw new ApiError("邮箱格式不正确");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateIdeaContent(raw: unknown): string {
|
||||||
|
if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
|
||||||
|
throw new ApiError("内容不能为空");
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed.length > 200) {
|
||||||
|
throw new ApiError("内容不能超过 200 字");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRoomName(raw: unknown, fallback = "我们的周末"): string {
|
||||||
|
const trimmed = ((raw as string) || "").trim() || fallback;
|
||||||
|
if (trimmed.length > 30) {
|
||||||
|
throw new ApiError("房间名不能超过 30 个字");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireString(value: unknown, fieldName: string): string {
|
||||||
|
if (!value || typeof value !== "string") {
|
||||||
|
throw new ApiError(`${fieldName}不能为空`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user