fix: SSE 认证 + 收藏去重 + 数据库索引和级联删除
- #7: SSE events 接口校验 userId 房间成员身份,start() 加 try/catch - #9: Favorite 新增 restaurantId 字段做精确去重,不再用 JSON contains - #10: 补齐 Decision/Favorite/Room/BlindBoxIdea 缺失索引 - #11: Decision/Favorite/BlindBoxMember/BlindBoxIdea 加 onDelete Cascade
This commit is contained in:
+17
-5
@@ -12,6 +12,8 @@ model Room {
|
|||||||
data String
|
data String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
|
|
||||||
|
@@index([expiresAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -40,15 +42,22 @@ model Decision {
|
|||||||
matchType String
|
matchType String
|
||||||
participants Int
|
participants Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([roomId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Favorite {
|
model Favorite {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
restaurantId String @default("")
|
||||||
restaurantData String
|
restaurantData String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, restaurantId])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model BlindBoxRoom {
|
model BlindBoxRoom {
|
||||||
@@ -70,7 +79,7 @@ model BlindBoxMember {
|
|||||||
joinedAt DateTime @default(now())
|
joinedAt DateTime @default(now())
|
||||||
|
|
||||||
room BlindBoxRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
room BlindBoxRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([roomId, userId])
|
@@unique([roomId, userId])
|
||||||
}
|
}
|
||||||
@@ -85,6 +94,9 @@ model BlindBoxIdea {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
room BlindBoxRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
room BlindBoxRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||||
user User @relation("IdeaSubmitter", fields: [userId], references: [id])
|
user User @relation("IdeaSubmitter", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
drawnBy User? @relation("IdeaDrawer", fields: [drawnById], references: [id])
|
drawnBy User? @relation("IdeaDrawer", fields: [drawnById], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([roomId, status])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { buildRoomStatus } from "@/lib/buildRoomStatus";
|
import { buildRoomStatus } from "@/lib/buildRoomStatus";
|
||||||
|
import { getRoomData } from "@/lib/store";
|
||||||
import { subscribe } from "@/lib/roomEvents";
|
import { subscribe } from "@/lib/roomEvents";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -9,6 +10,19 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const userId = url.searchParams.get("userId");
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
const data = await getRoomData(id);
|
||||||
|
if (data && !data.users.includes(userId)) {
|
||||||
|
return new Response(JSON.stringify({ error: "not_a_member" }), {
|
||||||
|
status: 403,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
@@ -26,13 +40,18 @@ export async function GET(
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const status = await buildRoomStatus(id);
|
try {
|
||||||
if (!status) {
|
const status = await buildRoomStatus(id);
|
||||||
send({ error: "room_not_found" });
|
if (!status) {
|
||||||
|
send({ error: "room_not_found" });
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (alive) send(status);
|
||||||
|
} catch {
|
||||||
|
send({ error: "load_failed" });
|
||||||
controller.close();
|
controller.close();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (alive) send(status);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const unsubscribe = subscribe(id, async () => {
|
const unsubscribe = subscribe(id, async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api";
|
import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api";
|
||||||
|
|
||||||
export const GET = apiHandler(async (req) => {
|
export const GET = apiHandler(async (req) => {
|
||||||
@@ -13,11 +14,11 @@ export const GET = apiHandler(async (req) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
favorites.map((f) => ({
|
favorites.map((f) => {
|
||||||
id: f.id,
|
let restaurantData = {};
|
||||||
restaurantData: JSON.parse(f.restaurantData),
|
try { restaurantData = JSON.parse(f.restaurantData); } catch { /* ignore */ }
|
||||||
createdAt: f.createdAt.toISOString(),
|
return { id: f.id, restaurantData, createdAt: f.createdAt.toISOString() };
|
||||||
})),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,29 +26,30 @@ export const POST = apiHandler(async (req) => {
|
|||||||
const { userId, restaurant } = await req.json();
|
const { userId, restaurant } = await req.json();
|
||||||
|
|
||||||
requireUserId(userId);
|
requireUserId(userId);
|
||||||
if (!restaurant) throw new ApiError("缺少必要字段");
|
if (!restaurant?.id || typeof restaurant.id !== "string") {
|
||||||
|
throw new ApiError("缺少必要字段");
|
||||||
|
}
|
||||||
|
|
||||||
await requireUser(userId);
|
await requireUser(userId);
|
||||||
|
|
||||||
const existing = await prisma.favorite.findFirst({
|
try {
|
||||||
where: {
|
const fav = await prisma.favorite.create({
|
||||||
userId,
|
data: {
|
||||||
restaurantData: { contains: `"id":"${restaurant.id}"` },
|
userId,
|
||||||
},
|
restaurantId: restaurant.id,
|
||||||
});
|
restaurantData: JSON.stringify(restaurant),
|
||||||
|
},
|
||||||
if (existing) {
|
});
|
||||||
return NextResponse.json({ id: existing.id, alreadyExists: true });
|
return NextResponse.json({ id: fav.id });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||||
|
const existing = await prisma.favorite.findFirst({
|
||||||
|
where: { userId, restaurantId: restaurant.id },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ id: existing?.id ?? "", alreadyExists: true });
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fav = await prisma.favorite.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
restaurantData: JSON.stringify(restaurant),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ id: fav.id });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const DELETE = apiHandler(async (req) => {
|
export const DELETE = apiHandler(async (req) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user