From dfb3cfa13613cd75c54d95ff5f11928244059280 Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 20:19:56 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=9C=8D=E5=8A=A1=E7=AB=AF=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=BC=BA=E5=8C=96=20=E2=80=94=20=E6=88=BF=E9=97=B4ID/?= =?UTF-8?q?=E5=9D=90=E6=A0=87/swipe/=E7=9B=B2=E7=9B=92=E7=AB=9E=E6=80=81/?= =?UTF-8?q?=E7=A9=BA=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #15: 房间 ID 扩展为 6 位字母数字,createRoom 用 P2002 重试替代 find-then-create - #16: 盲盒编辑/删除改用 updateMany/deleteMany 原子操作,防止 TOCTOU - #17: lat/lng 用 Number.isFinite + 范围校验 (-90~90, -180~180) - #18: swipe action 必须为 'like' 或 'pass' - #19: user PUT 的 JSON.parse(preferences) 加 try/catch - #26: requireString 拒绝纯空格字符串 --- src/app/api/blindbox/route.ts | 22 ++++++++----------- src/app/api/room/[id]/swipe/route.ts | 3 +++ src/app/api/room/create/route.ts | 7 ++++-- src/app/api/user/route.ts | 5 ++++- src/lib/store.ts | 33 ++++++++++++++++------------ src/lib/validation.ts | 2 +- 6 files changed, 41 insertions(+), 31 deletions(-) diff --git a/src/app/api/blindbox/route.ts b/src/app/api/blindbox/route.ts index a5c4de2..38a0912 100644 --- a/src/app/api/blindbox/route.ts +++ b/src/app/api/blindbox/route.ts @@ -55,17 +55,14 @@ export const PUT = apiHandler(async (req) => { requireString(ideaId, "ideaId"); const trimmedContent = validateIdeaContent(content); - const idea = await prisma.blindBoxIdea.findUnique({ where: { id: ideaId } }); - if (!idea) throw new ApiError("想法不存在", 404); - if (idea.userId !== userId) throw new ApiError("只能编辑自己的想法", 403); - if (idea.status !== "in_pool") throw new ApiError("已抽中的想法不能编辑"); - - const updated = await prisma.blindBoxIdea.update({ - where: { id: ideaId }, + const { count } = await prisma.blindBoxIdea.updateMany({ + where: { id: ideaId, userId, status: "in_pool" }, data: { content: trimmedContent }, }); - return NextResponse.json({ id: updated.id, content: updated.content }); + if (count === 0) throw new ApiError("想法不存在、已被抽中或无权编辑", 404); + + return NextResponse.json({ id: ideaId, content: trimmedContent }); }); export const DELETE = apiHandler(async (req) => { @@ -74,12 +71,11 @@ export const DELETE = apiHandler(async (req) => { requireUserId(userId); requireString(ideaId, "ideaId"); - const idea = await prisma.blindBoxIdea.findUnique({ where: { id: ideaId } }); - if (!idea) throw new ApiError("想法不存在", 404); - if (idea.userId !== userId) throw new ApiError("只能删除自己的想法", 403); - if (idea.status !== "in_pool") throw new ApiError("已抽中的想法不能删除"); + const { count } = await prisma.blindBoxIdea.deleteMany({ + where: { id: ideaId, userId, status: "in_pool" }, + }); - await prisma.blindBoxIdea.delete({ where: { id: ideaId } }); + if (count === 0) throw new ApiError("想法不存在、已被抽中或无权删除", 404); return NextResponse.json({ deleted: true }); }); diff --git a/src/app/api/room/[id]/swipe/route.ts b/src/app/api/room/[id]/swipe/route.ts index 879eb7a..88663af 100644 --- a/src/app/api/room/[id]/swipe/route.ts +++ b/src/app/api/room/[id]/swipe/route.ts @@ -11,6 +11,9 @@ export const POST = apiHandler(async (req, { params }) => { if (restaurantId == null || !action) { throw new ApiError("restaurantId and action are required"); } + if (action !== "like" && action !== "pass") { + throw new ApiError("action must be 'like' or 'pass'"); + } const rid = String(restaurantId); diff --git a/src/app/api/room/create/route.ts b/src/app/api/room/create/route.ts index e0cfe35..0ba1ba1 100644 --- a/src/app/api/room/create/route.ts +++ b/src/app/api/room/create/route.ts @@ -117,7 +117,10 @@ export const POST = apiHandler(async (req) => { const sceneConfig = getSceneConfig(scene === "drink" ? "drink" : "eat"); - if (!lat || !lng) { + const numLat = Number(lat); + const numLng = Number(lng); + if (!Number.isFinite(numLat) || !Number.isFinite(numLng) || + numLat < -90 || numLat > 90 || numLng < -180 || numLng > 180) { throw new ApiError("无法获取位置信息,请允许定位权限后重试"); } @@ -125,7 +128,7 @@ export const POST = apiHandler(async (req) => { const url = new URL("https://restapi.amap.com/v5/place/around"); url.searchParams.set("key", apiKey); - url.searchParams.set("location", `${lng},${lat}`); + url.searchParams.set("location", `${numLng},${numLat}`); url.searchParams.set("radius", String(radius)); url.searchParams.set("types", sceneConfig.poiTypes); url.searchParams.set("show_fields", "business,photos"); diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index d9a0a2d..9db9ed2 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -73,12 +73,15 @@ export const PUT = apiHandler(async (req) => { data: updateData, }); + let prefs = {}; + try { prefs = JSON.parse(user.preferences); } catch { /* fallback */ } + return NextResponse.json({ id: user.id, username: user.username, avatar: user.avatar, email: user.email, - preferences: JSON.parse(user.preferences), + preferences: prefs, }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") { diff --git a/src/lib/store.ts b/src/lib/store.ts index cfb8f1f..8e2d775 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -1,4 +1,5 @@ import { prisma } from "./prisma"; +import { Prisma } from "@prisma/client"; import { Restaurant, SceneType } from "@/types"; const ROOM_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -15,8 +16,14 @@ export interface RoomData { scene: SceneType; } +const ROOM_ID_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + function generateRoomId(): string { - return String(Math.floor(1000 + Math.random() * 9000)); + let id = ""; + for (let i = 0; i < 6; i++) { + id += ROOM_ID_CHARS[Math.floor(Math.random() * ROOM_ID_CHARS.length)]; + } + return id; } function normalize(raw: Partial): RoomData { @@ -69,26 +76,24 @@ export async function createRoom(restaurants: Restaurant[], creatorId: string, s }; const expiresAt = new Date(Date.now() + ROOM_TTL_MS); - let roomId: string; - let attempts = 0; + const payload = JSON.stringify(data); - while (attempts < 20) { - roomId = generateRoomId(); - const existing = await prisma.room.findUnique({ where: { id: roomId } }); - if (!existing) { + for (let attempts = 0; attempts < 20; attempts++) { + const roomId = generateRoomId(); + try { await prisma.room.create({ - data: { id: roomId, data: JSON.stringify(data), expiresAt }, + data: { id: roomId, data: payload, expiresAt }, }); return roomId; + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") { + continue; + } + throw e; } - attempts++; } - roomId = generateRoomId() + String(Date.now()).slice(-2); - await prisma.room.create({ - data: { id: roomId, data: JSON.stringify(data), expiresAt }, - }); - return roomId; + throw new Error("无法生成唯一房间号,请稍后重试"); } export async function getRoomData( diff --git a/src/lib/validation.ts b/src/lib/validation.ts index f400757..0c9082b 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -43,7 +43,7 @@ export function validateRoomName(raw: unknown, fallback = "我们的周末"): st } export function requireString(value: unknown, fieldName: string): string { - if (!value || typeof value !== "string") { + if (!value || typeof value !== "string" || !value.trim()) { throw new ApiError(`${fieldName}不能为空`); } return value;