fix: 服务端验证强化 — 房间ID/坐标/swipe/盲盒竞态/空格

- #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 拒绝纯空格字符串
This commit is contained in:
2026-02-26 20:19:56 +08:00
parent 93f20747e4
commit dfb3cfa136
6 changed files with 41 additions and 31 deletions
+9 -13
View File
@@ -55,17 +55,14 @@ export const PUT = apiHandler(async (req) => {
requireString(ideaId, "ideaId"); requireString(ideaId, "ideaId");
const trimmedContent = validateIdeaContent(content); const trimmedContent = validateIdeaContent(content);
const idea = await prisma.blindBoxIdea.findUnique({ where: { id: ideaId } }); const { count } = await prisma.blindBoxIdea.updateMany({
if (!idea) throw new ApiError("想法不存在", 404); where: { id: ideaId, userId, status: "in_pool" },
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 },
data: { content: trimmedContent }, 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) => { export const DELETE = apiHandler(async (req) => {
@@ -74,12 +71,11 @@ export const DELETE = apiHandler(async (req) => {
requireUserId(userId); requireUserId(userId);
requireString(ideaId, "ideaId"); requireString(ideaId, "ideaId");
const idea = await prisma.blindBoxIdea.findUnique({ where: { id: ideaId } }); const { count } = await prisma.blindBoxIdea.deleteMany({
if (!idea) throw new ApiError("想法不存在", 404); where: { id: ideaId, userId, status: "in_pool" },
if (idea.userId !== userId) throw new ApiError("只能删除自己的想法", 403); });
if (idea.status !== "in_pool") throw new ApiError("已抽中的想法不能删除");
await prisma.blindBoxIdea.delete({ where: { id: ideaId } }); if (count === 0) throw new ApiError("想法不存在、已被抽中或无权删除", 404);
return NextResponse.json({ deleted: true }); return NextResponse.json({ deleted: true });
}); });
+3
View File
@@ -11,6 +11,9 @@ export const POST = apiHandler(async (req, { params }) => {
if (restaurantId == null || !action) { if (restaurantId == null || !action) {
throw new ApiError("restaurantId and action are required"); 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); const rid = String(restaurantId);
+5 -2
View File
@@ -117,7 +117,10 @@ export const POST = apiHandler(async (req) => {
const sceneConfig = getSceneConfig(scene === "drink" ? "drink" : "eat"); 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("无法获取位置信息,请允许定位权限后重试"); throw new ApiError("无法获取位置信息,请允许定位权限后重试");
} }
@@ -125,7 +128,7 @@ export const POST = apiHandler(async (req) => {
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);
url.searchParams.set("location", `${lng},${lat}`); url.searchParams.set("location", `${numLng},${numLat}`);
url.searchParams.set("radius", String(radius)); url.searchParams.set("radius", String(radius));
url.searchParams.set("types", sceneConfig.poiTypes); url.searchParams.set("types", sceneConfig.poiTypes);
url.searchParams.set("show_fields", "business,photos"); url.searchParams.set("show_fields", "business,photos");
+4 -1
View File
@@ -73,12 +73,15 @@ export const PUT = apiHandler(async (req) => {
data: updateData, data: updateData,
}); });
let prefs = {};
try { prefs = JSON.parse(user.preferences); } catch { /* fallback */ }
return NextResponse.json({ return NextResponse.json({
id: user.id, id: user.id,
username: user.username, username: user.username,
avatar: user.avatar, avatar: user.avatar,
email: user.email, email: user.email,
preferences: JSON.parse(user.preferences), preferences: prefs,
}); });
} catch (e) { } catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
+19 -14
View File
@@ -1,4 +1,5 @@
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { Prisma } from "@prisma/client";
import { Restaurant, SceneType } from "@/types"; import { Restaurant, SceneType } from "@/types";
const ROOM_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours const ROOM_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -15,8 +16,14 @@ export interface RoomData {
scene: SceneType; scene: SceneType;
} }
const ROOM_ID_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
function generateRoomId(): string { 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>): RoomData { function normalize(raw: Partial<RoomData>): RoomData {
@@ -69,26 +76,24 @@ export async function createRoom(restaurants: Restaurant[], creatorId: string, s
}; };
const expiresAt = new Date(Date.now() + ROOM_TTL_MS); const expiresAt = new Date(Date.now() + ROOM_TTL_MS);
let roomId: string; const payload = JSON.stringify(data);
let attempts = 0;
while (attempts < 20) { for (let attempts = 0; attempts < 20; attempts++) {
roomId = generateRoomId(); const roomId = generateRoomId();
const existing = await prisma.room.findUnique({ where: { id: roomId } }); try {
if (!existing) {
await prisma.room.create({ await prisma.room.create({
data: { id: roomId, data: JSON.stringify(data), expiresAt }, data: { id: roomId, data: payload, expiresAt },
}); });
return roomId; return roomId;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
continue;
}
throw e;
} }
attempts++;
} }
roomId = generateRoomId() + String(Date.now()).slice(-2); throw new Error("无法生成唯一房间号,请稍后重试");
await prisma.room.create({
data: { id: roomId, data: JSON.stringify(data), expiresAt },
});
return roomId;
} }
export async function getRoomData( export async function getRoomData(
+1 -1
View File
@@ -43,7 +43,7 @@ export function validateRoomName(raw: unknown, fallback = "我们的周末"): st
} }
export function requireString(value: unknown, fieldName: string): string { export function requireString(value: unknown, fieldName: string): string {
if (!value || typeof value !== "string") { if (!value || typeof value !== "string" || !value.trim()) {
throw new ApiError(`${fieldName}不能为空`); throw new ApiError(`${fieldName}不能为空`);
} }
return value; return value;