diff --git a/src/app/api/room/[id]/join/route.ts b/src/app/api/room/[id]/join/route.ts index 22663d4..f4e32e4 100644 --- a/src/app/api/room/[id]/join/route.ts +++ b/src/app/api/room/[id]/join/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getRoomData, updateRoomData } from "@/lib/store"; +import { atomicUpdateRoom } from "@/lib/store"; export async function POST( req: Request, @@ -8,28 +8,28 @@ export async function POST( const { id } = await params; try { - const data = await getRoomData(id); + const { userId } = await req.json(); + if (!userId) { + return NextResponse.json({ error: "userId required" }, { status: 400 }); + } - if (!data) { + const updated = await atomicUpdateRoom(id, (data) => { + if (!data.users.includes(userId)) { + data.users.push(userId); + } + return data; + }); + + if (!updated) { return NextResponse.json( { error: "房间不存在或已过期" }, { status: 404 }, ); } - const { userId } = await req.json(); - if (!userId) { - return NextResponse.json({ error: "userId required" }, { status: 400 }); - } - - if (!data.users.includes(userId)) { - data.users.push(userId); - await updateRoomData(id, data); - } - return NextResponse.json({ roomId: id, - userCount: data.users.length, + userCount: updated.users.length, }); } catch (e) { console.error("Failed to join room:", e); diff --git a/src/app/api/room/[id]/reset/route.ts b/src/app/api/room/[id]/reset/route.ts new file mode 100644 index 0000000..9609cc1 --- /dev/null +++ b/src/app/api/room/[id]/reset/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { atomicUpdateRoom } from "@/lib/store"; + +export async function POST( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + try { + const updated = await atomicUpdateRoom(id, (data) => { + data.likes = {}; + data.swipeCounts = {}; + data.match = null; + return data; + }); + + if (!updated) { + return NextResponse.json( + { error: "房间不存在或已过期" }, + { status: 404 }, + ); + } + + return NextResponse.json({ ok: true }); + } catch (e) { + console.error("Failed to reset room:", e); + return NextResponse.json( + { error: "重置失败" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/room/[id]/route.ts b/src/app/api/room/[id]/route.ts index ad0b6b9..c53a327 100644 --- a/src/app/api/room/[id]/route.ts +++ b/src/app/api/room/[id]/route.ts @@ -17,10 +17,17 @@ export async function GET( ); } + const total = data.restaurants.length; + const allFinished = + data.users.length > 0 && + data.users.every((u) => (data.swipeCounts[u] ?? 0) >= total); + const noMatch = allFinished && data.match === null; + return NextResponse.json({ roomId: id, userCount: data.users.length, match: data.match, + noMatch, restaurants: data.restaurants, }); } catch (e) { diff --git a/src/app/api/room/[id]/swipe/route.ts b/src/app/api/room/[id]/swipe/route.ts index 380a3c6..a7ead13 100644 --- a/src/app/api/room/[id]/swipe/route.ts +++ b/src/app/api/room/[id]/swipe/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getRoomData, updateRoomData } from "@/lib/store"; +import { atomicUpdateRoom } from "@/lib/store"; export async function POST( req: Request, @@ -8,15 +8,6 @@ export async function POST( const { id } = await params; try { - const data = await getRoomData(id); - - if (!data) { - return NextResponse.json( - { error: "房间不存在或已过期" }, - { status: 404 }, - ); - } - const { userId, restaurantId, action } = await req.json(); if (!userId || restaurantId == null || !action) { @@ -27,33 +18,39 @@ export async function POST( } const rid = String(restaurantId); - let dirty = false; - if (action === "like") { - if (!data.likes[rid]) { - data.likes[rid] = []; - } - if (!data.likes[rid].includes(userId)) { - data.likes[rid].push(userId); - dirty = true; + const updated = await atomicUpdateRoom(id, (data) => { + if (action === "like") { + if (!data.likes[rid]) { + data.likes[rid] = []; + } + if (!data.likes[rid].includes(userId)) { + data.likes[rid].push(userId); + } + + if ( + data.users.length > 1 && + data.likes[rid].length === data.users.length + ) { + data.match = rid; + } } - if ( - data.users.length > 1 && - data.likes[rid].length === data.users.length - ) { - data.match = rid; - dirty = true; - } - } + data.swipeCounts[userId] = (data.swipeCounts[userId] ?? 0) + 1; - if (dirty) { - await updateRoomData(id, data); + return data; + }); + + if (!updated) { + return NextResponse.json( + { error: "房间不存在或已过期" }, + { status: 404 }, + ); } return NextResponse.json({ - match: data.match, - likeCount: data.likes[rid]?.length ?? 0, + match: updated.match, + likeCount: updated.likes[rid]?.length ?? 0, }); } catch (e) { console.error("Failed to process swipe:", e); diff --git a/src/app/page.tsx b/src/app/page.tsx index 5a8572e..dde34f4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -61,7 +61,15 @@ export default function LandingPage() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(coords), }); + + if (!res.ok) { + throw new Error("创建房间失败"); + } + const { roomId } = await res.json(); + if (!roomId) { + throw new Error("创建房间失败"); + } setLoadingText("正在进入房间..."); await joinRoom(roomId); diff --git a/src/app/room/[id]/page.tsx b/src/app/room/[id]/page.tsx index bf721ce..45e2b72 100644 --- a/src/app/room/[id]/page.tsx +++ b/src/app/room/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { useParams } from "next/navigation"; import TopNav from "@/components/TopNav"; import SwipeDeck from "@/components/SwipeDeck"; @@ -14,7 +14,8 @@ export default function RoomPage() { const [userId, setUserId] = useState(""); const [joined, setJoined] = useState(false); - const { userCount, match, restaurants } = useRoomPolling(roomId); + const { userCount, match, noMatch, restaurants, mutate } = + useRoomPolling(roomId); useEffect(() => { const id = getUserId(); @@ -27,6 +28,11 @@ export default function RoomPage() { }).then(() => setJoined(true)); }, [roomId]); + const handleReset = useCallback(async () => { + await fetch(`/api/room/${roomId}/reset`, { method: "POST" }); + await mutate(); + }, [roomId, mutate]); + const ready = joined && userId && restaurants.length > 0; if (!ready) { @@ -46,6 +52,8 @@ export default function RoomPage() { roomId={roomId} userId={userId} matchedRestaurantId={match} + noMatch={noMatch} + onReset={handleReset} /> ); diff --git a/src/components/MatchResult.tsx b/src/components/MatchResult.tsx index 6c9e4b9..2d042cf 100644 --- a/src/components/MatchResult.tsx +++ b/src/components/MatchResult.tsx @@ -13,7 +13,7 @@ import { Restaurant } from "@/types"; interface MatchResultProps { restaurant: Restaurant; - onReset: () => void; + onReset: () => Promise; } function buildNavUrl(restaurant: Restaurant): string { diff --git a/src/components/SwipeDeck.tsx b/src/components/SwipeDeck.tsx index ee9689c..29d2a1c 100644 --- a/src/components/SwipeDeck.tsx +++ b/src/components/SwipeDeck.tsx @@ -1,17 +1,20 @@ "use client"; import { useState, useCallback, useRef, useEffect } from "react"; -import { AnimatePresence } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; import SwipeableCard from "./SwipeableCard"; import ActionButtons from "./ActionButtons"; import MatchResult from "./MatchResult"; import { Restaurant, SwipeDirection } from "@/types"; +import { Frown, RotateCcw } from "lucide-react"; interface SwipeDeckProps { restaurants: Restaurant[]; roomId: string; userId: string; matchedRestaurantId: string | null; + noMatch: boolean; + onReset: () => Promise; } export default function SwipeDeck({ @@ -19,20 +22,23 @@ export default function SwipeDeck({ roomId, userId, matchedRestaurantId, + noMatch, + onReset, }: SwipeDeckProps) { const [currentIndex, setCurrentIndex] = useState(0); const [showMatch, setShowMatch] = useState(false); const [localMatchId, setLocalMatchId] = useState(null); + const [resetting, setResetting] = useState(false); const swipeFnRef = useRef<((direction: SwipeDirection) => void) | null>(null); const swipingRef = useRef(false); const resolvedMatchId = matchedRestaurantId ?? localMatchId; useEffect(() => { - if (matchedRestaurantId != null && !showMatch) { + if (resolvedMatchId != null && !showMatch) { setShowMatch(true); } - }, [matchedRestaurantId, showMatch]); + }, [resolvedMatchId, showMatch]); const registerSwipe = useCallback( (fn: (direction: SwipeDirection) => void) => { @@ -70,15 +76,9 @@ export default function SwipeDeck({ const nextIndex = currentIndex + 1; setCurrentIndex(nextIndex); swipeFnRef.current = null; - - if (nextIndex >= restaurants.length && !resolvedMatchId) { - setTimeout(() => { - if (!showMatch) setShowMatch(true); - }, 300); - } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [currentIndex, restaurants, roomId, userId, resolvedMatchId, showMatch], + [currentIndex, restaurants, roomId, userId], ); const handleButtonAction = useCallback( @@ -91,26 +91,32 @@ export default function SwipeDeck({ [], ); - const handleReset = useCallback(() => { - setCurrentIndex(0); - setShowMatch(false); - setLocalMatchId(null); - }, []); + const handleReset = useCallback(async () => { + setResetting(true); + try { + await onReset(); + setCurrentIndex(0); + setShowMatch(false); + setLocalMatchId(null); + } finally { + setResetting(false); + } + }, [onReset]); - const isDone = currentIndex >= restaurants.length || resolvedMatchId != null; + const allSwiped = currentIndex >= restaurants.length; + const isDone = allSwiped || resolvedMatchId != null; const matchRestaurant = resolvedMatchId - ? restaurants.find((r) => r.id === resolvedMatchId) ?? restaurants[0] - : restaurants[0]; + ? restaurants.find((r) => r.id === resolvedMatchId) ?? null + : null; - const showWaiting = - currentIndex >= restaurants.length && !resolvedMatchId && !showMatch; + const showWaiting = allSwiped && !resolvedMatchId && !noMatch; return ( <>
- {!resolvedMatchId && ( + {!resolvedMatchId && !noMatch && ( {restaurants.map((restaurant, index) => { if (index < currentIndex || index > currentIndex + 1) @@ -135,10 +141,36 @@ export default function SwipeDeck({

等待其他人完成选择...

)} + + {noMatch && !showMatch && ( + +
+ +
+
+

没有达成共识

+

+ 大家口味不太一样,换一批试试? +

+
+ +
+ )}
- + {showMatch && matchRestaurant && ( diff --git a/src/hooks/useRoomPolling.ts b/src/hooks/useRoomPolling.ts index 2fb1d1b..031b589 100644 --- a/src/hooks/useRoomPolling.ts +++ b/src/hooks/useRoomPolling.ts @@ -1,22 +1,36 @@ "use client"; import useSWR from "swr"; +import { useRef } from "react"; import { RoomStatus } from "@/types"; const fetcher = (url: string) => fetch(url).then((r) => r.json()); export function useRoomPolling(roomId: string) { - const { data, error, isLoading } = useSWR( + const settled = useRef(false); + + const { data, error, isLoading, mutate } = useSWR( `/api/room/${roomId}`, fetcher, - { refreshInterval: 1500, revalidateOnFocus: true }, + { + refreshInterval: settled.current ? 0 : 1500, + revalidateOnFocus: true, + }, ); + if (data?.match != null || data?.noMatch) { + settled.current = true; + } else { + settled.current = false; + } + return { userCount: data?.userCount ?? 0, match: data?.match ?? null, + noMatch: data?.noMatch ?? false, restaurants: data?.restaurants ?? [], isLoading, error, + mutate, }; } diff --git a/src/lib/store.ts b/src/lib/store.ts index 1bbe859..5097ae0 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -5,6 +5,7 @@ export interface RoomData { users: string[]; restaurants: Restaurant[]; likes: Record; + swipeCounts: Record; match: string | null; } @@ -12,11 +13,22 @@ function generateRoomId(): string { return String(Math.floor(1000 + Math.random() * 9000)); } +function normalize(raw: Partial): RoomData { + return { + users: raw.users ?? [], + restaurants: raw.restaurants ?? [], + likes: raw.likes ?? {}, + swipeCounts: raw.swipeCounts ?? {}, + match: raw.match ?? null, + }; +} + export async function createRoom(restaurants: Restaurant[]): Promise { const data: RoomData = { users: [], restaurants, likes: {}, + swipeCounts: {}, match: null, }; @@ -47,15 +59,29 @@ export async function getRoomData( ): Promise { const room = await prisma.room.findUnique({ where: { id: roomId } }); if (!room) return null; - return JSON.parse(room.data) as RoomData; + return normalize(JSON.parse(room.data)); } -export async function updateRoomData( +/** + * Atomic read-modify-write within a Prisma transaction. + * Prevents race conditions when multiple users swipe concurrently. + */ +export async function atomicUpdateRoom( roomId: string, - data: RoomData, -): Promise { - await prisma.room.update({ - where: { id: roomId }, - data: { data: JSON.stringify(data) }, + updater: (data: RoomData) => RoomData, +): Promise { + return prisma.$transaction(async (tx) => { + const room = await tx.room.findUnique({ where: { id: roomId } }); + if (!room) return null; + + const data = normalize(JSON.parse(room.data)); + const updated = updater(data); + + await tx.room.update({ + where: { id: roomId }, + data: { data: JSON.stringify(updated) }, + }); + + return updated; }); } diff --git a/src/types/index.ts b/src/types/index.ts index facab0a..7fdca21 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,5 +19,6 @@ export interface RoomStatus { roomId: string; userCount: number; match: string | null; + noMatch: boolean; restaurants: Restaurant[]; }