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 bcrypt from "bcryptjs";
|
||||
import { apiHandler, ApiError } from "@/lib/api";
|
||||
import { validateUsername, validatePassword } from "@/lib/validation";
|
||||
|
||||
export const POST = apiHandler(async (req) => {
|
||||
const { username, password, avatar } = await req.json();
|
||||
|
||||
if (!username || !password) throw new ApiError("用户名和密码为必填项");
|
||||
|
||||
const trimmedUsername = username.trim();
|
||||
if (trimmedUsername.length < 2 || trimmedUsername.length > 16) {
|
||||
throw new ApiError("用户名需要 2-16 个字符");
|
||||
}
|
||||
if (password.length < 6) throw new ApiError("密码至少 6 个字符");
|
||||
const trimmedUsername = validateUsername(username);
|
||||
validatePassword(password);
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { username: trimmedUsername } });
|
||||
if (existing) throw new ApiError("用户名已被注册", 409);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
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) => {
|
||||
const { userId, name } = await req.json();
|
||||
|
||||
requireUserId(userId);
|
||||
|
||||
const roomName = (name || "").trim() || "我们的周末";
|
||||
if (roomName.length > 30) throw new ApiError("房间名不能超过 30 个字");
|
||||
const roomName = validateRoomName(name);
|
||||
|
||||
await requireUser(userId);
|
||||
|
||||
|
||||
@@ -2,31 +2,27 @@ import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireMembership } from "@/lib/blindbox";
|
||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
||||
import { validateIdeaContent, requireString } from "@/lib/validation";
|
||||
|
||||
export const POST = apiHandler(async (req) => {
|
||||
const { roomId, userId, content } = await req.json();
|
||||
|
||||
requireUserId(userId);
|
||||
if (!roomId || typeof roomId !== "string") throw new ApiError("roomId 不能为空");
|
||||
if (!content || typeof content !== "string" || content.trim().length === 0) {
|
||||
throw new ApiError("内容不能为空");
|
||||
}
|
||||
if (content.trim().length > 200) throw new ApiError("内容不能超过 200 字");
|
||||
requireString(roomId, "roomId");
|
||||
const trimmedContent = validateIdeaContent(content);
|
||||
|
||||
await requireMembership(roomId, userId);
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (req) => {
|
||||
const roomId = req.nextUrl.searchParams.get("roomId");
|
||||
const userId = requireUserId(req.nextUrl.searchParams.get("userId"));
|
||||
|
||||
if (!roomId) throw new ApiError("缺少 roomId");
|
||||
const roomId = requireString(req.nextUrl.searchParams.get("roomId"), "roomId");
|
||||
|
||||
await requireMembership(roomId, userId);
|
||||
|
||||
@@ -56,11 +52,8 @@ export const PUT = apiHandler(async (req) => {
|
||||
const { ideaId, userId, content } = await req.json();
|
||||
|
||||
requireUserId(userId);
|
||||
if (!ideaId) throw new ApiError("缺少 ideaId");
|
||||
if (!content || typeof content !== "string" || content.trim().length === 0) {
|
||||
throw new ApiError("内容不能为空");
|
||||
}
|
||||
if (content.trim().length > 200) throw new ApiError("内容不能超过 200 字");
|
||||
requireString(ideaId, "ideaId");
|
||||
const trimmedContent = validateIdeaContent(content);
|
||||
|
||||
const idea = await prisma.blindBoxIdea.findUnique({ where: { id: ideaId } });
|
||||
if (!idea) throw new ApiError("想法不存在", 404);
|
||||
@@ -69,7 +62,7 @@ export const PUT = apiHandler(async (req) => {
|
||||
|
||||
const updated = await prisma.blindBoxIdea.update({
|
||||
where: { id: ideaId },
|
||||
data: { content: content.trim() },
|
||||
data: { content: trimmedContent },
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
requireUserId(userId);
|
||||
if (!ideaId) throw new ApiError("缺少 ideaId");
|
||||
requireString(ideaId, "ideaId");
|
||||
|
||||
const idea = await prisma.blindBoxIdea.findUnique({ where: { id: ideaId } });
|
||||
if (!idea) throw new ApiError("想法不存在", 404);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler, ApiError } from "@/lib/api";
|
||||
import { requireAmapApiKey } from "@/lib/amap";
|
||||
|
||||
export const GET = apiHandler(async (req) => {
|
||||
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");
|
||||
|
||||
const apiKey = process.env.AMAP_API_KEY;
|
||||
if (!apiKey) throw new ApiError("AMAP_API_KEY not configured", 500);
|
||||
const apiKey = requireAmapApiKey();
|
||||
|
||||
const url = new URL("https://restapi.amap.com/v3/geocode/regeo");
|
||||
url.searchParams.set("key", apiKey);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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) => {
|
||||
const keywords = req.nextUrl.searchParams.get("keywords")?.trim();
|
||||
if (!keywords) return NextResponse.json([]);
|
||||
|
||||
const apiKey = process.env.AMAP_API_KEY;
|
||||
if (!apiKey) throw new ApiError("AMAP_API_KEY not configured", 500);
|
||||
const apiKey = requireAmapApiKey();
|
||||
|
||||
const url = new URL("https://restapi.amap.com/v3/assistant/inputtips");
|
||||
url.searchParams.set("key", apiKey);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoom } from "@/lib/store";
|
||||
import { Restaurant, SceneType } from "@/types";
|
||||
import { getSceneConfig } from "@/lib/sceneConfig";
|
||||
import { apiHandler, ApiError } from "@/lib/api";
|
||||
import { requireAmapApiKey } from "@/lib/amap";
|
||||
|
||||
interface AmapPoiV5 {
|
||||
id: string;
|
||||
@@ -120,10 +121,7 @@ export const POST = apiHandler(async (req) => {
|
||||
throw new ApiError("无法获取位置信息,请允许定位权限后重试");
|
||||
}
|
||||
|
||||
const apiKey = process.env.AMAP_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new ApiError("服务配置异常,请稍后重试", 500);
|
||||
}
|
||||
const apiKey = requireAmapApiKey();
|
||||
|
||||
const url = new URL("https://restapi.amap.com/v5/place/around");
|
||||
url.searchParams.set("key", apiKey);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api";
|
||||
import { validateUsername, validatePassword, validateEmail } from "@/lib/validation";
|
||||
|
||||
export const GET = apiHandler(async (req) => {
|
||||
const userId = req.nextUrl.searchParams.get("id");
|
||||
@@ -33,10 +34,7 @@ export const PUT = apiHandler(async (req) => {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (body.username !== undefined) {
|
||||
const trimmed = body.username.trim();
|
||||
if (trimmed.length < 2 || trimmed.length > 16) {
|
||||
throw new ApiError("用户名需要 2-16 个字符");
|
||||
}
|
||||
const trimmed = validateUsername(body.username);
|
||||
if (trimmed !== existing.username) {
|
||||
const taken = await prisma.user.findUnique({ where: { username: trimmed } });
|
||||
if (taken) throw new ApiError("用户名已被占用", 409);
|
||||
@@ -48,7 +46,7 @@ export const PUT = apiHandler(async (req) => {
|
||||
if (!body.currentPassword) throw new ApiError("请输入当前密码");
|
||||
const valid = await bcrypt.compare(body.currentPassword, existing.passwordHash);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -57,9 +55,7 @@ export const PUT = apiHandler(async (req) => {
|
||||
}
|
||||
|
||||
if (body.email !== undefined) {
|
||||
if (body.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
|
||||
throw new ApiError("邮箱格式不正确");
|
||||
}
|
||||
if (body.email) validateEmail(body.email);
|
||||
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