fix: 修复竞态条件、重置逻辑、无匹配终态等关键问题

- 用 Prisma $transaction 实现 atomicUpdateRoom,防止并发写入覆盖
- 新增 POST /api/room/[id]/reset 端点,修复"再来一轮"按钮死循环
- 新增 swipeCounts 字段追踪滑动进度,检测"无人匹配"终态
- 着陆页 handleCreate 增加 res.ok 检查,防止跳转到无效房间
- 匹配或无匹配后停止轮询,减少无效请求
This commit is contained in:
2026-02-24 17:04:16 +08:00
parent d87d30ccc0
commit 77d15f29e3
11 changed files with 204 additions and 78 deletions
+14 -14
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getRoomData, updateRoomData } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/store";
export async function POST( export async function POST(
req: Request, req: Request,
@@ -8,28 +8,28 @@ export async function POST(
const { id } = await params; const { id } = await params;
try { 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( return NextResponse.json(
{ error: "房间不存在或已过期" }, { error: "房间不存在或已过期" },
{ status: 404 }, { 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({ return NextResponse.json({
roomId: id, roomId: id,
userCount: data.users.length, userCount: updated.users.length,
}); });
} catch (e) { } catch (e) {
console.error("Failed to join room:", e); console.error("Failed to join room:", e);
+33
View File
@@ -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 },
);
}
}
+7
View File
@@ -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({ return NextResponse.json({
roomId: id, roomId: id,
userCount: data.users.length, userCount: data.users.length,
match: data.match, match: data.match,
noMatch,
restaurants: data.restaurants, restaurants: data.restaurants,
}); });
} catch (e) { } catch (e) {
+27 -30
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getRoomData, updateRoomData } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/store";
export async function POST( export async function POST(
req: Request, req: Request,
@@ -8,15 +8,6 @@ export async function POST(
const { id } = await params; const { id } = await params;
try { try {
const data = await getRoomData(id);
if (!data) {
return NextResponse.json(
{ error: "房间不存在或已过期" },
{ status: 404 },
);
}
const { userId, restaurantId, action } = await req.json(); const { userId, restaurantId, action } = await req.json();
if (!userId || restaurantId == null || !action) { if (!userId || restaurantId == null || !action) {
@@ -27,33 +18,39 @@ export async function POST(
} }
const rid = String(restaurantId); const rid = String(restaurantId);
let dirty = false;
if (action === "like") { const updated = await atomicUpdateRoom(id, (data) => {
if (!data.likes[rid]) { if (action === "like") {
data.likes[rid] = []; if (!data.likes[rid]) {
} data.likes[rid] = [];
if (!data.likes[rid].includes(userId)) { }
data.likes[rid].push(userId); if (!data.likes[rid].includes(userId)) {
dirty = true; data.likes[rid].push(userId);
}
if (
data.users.length > 1 &&
data.likes[rid].length === data.users.length
) {
data.match = rid;
}
} }
if ( data.swipeCounts[userId] = (data.swipeCounts[userId] ?? 0) + 1;
data.users.length > 1 &&
data.likes[rid].length === data.users.length
) {
data.match = rid;
dirty = true;
}
}
if (dirty) { return data;
await updateRoomData(id, data); });
if (!updated) {
return NextResponse.json(
{ error: "房间不存在或已过期" },
{ status: 404 },
);
} }
return NextResponse.json({ return NextResponse.json({
match: data.match, match: updated.match,
likeCount: data.likes[rid]?.length ?? 0, likeCount: updated.likes[rid]?.length ?? 0,
}); });
} catch (e) { } catch (e) {
console.error("Failed to process swipe:", e); console.error("Failed to process swipe:", e);
+8
View File
@@ -61,7 +61,15 @@ export default function LandingPage() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(coords), body: JSON.stringify(coords),
}); });
if (!res.ok) {
throw new Error("创建房间失败");
}
const { roomId } = await res.json(); const { roomId } = await res.json();
if (!roomId) {
throw new Error("创建房间失败");
}
setLoadingText("正在进入房间..."); setLoadingText("正在进入房间...");
await joinRoom(roomId); await joinRoom(roomId);
+10 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import TopNav from "@/components/TopNav"; import TopNav from "@/components/TopNav";
import SwipeDeck from "@/components/SwipeDeck"; import SwipeDeck from "@/components/SwipeDeck";
@@ -14,7 +14,8 @@ export default function RoomPage() {
const [userId, setUserId] = useState(""); const [userId, setUserId] = useState("");
const [joined, setJoined] = useState(false); const [joined, setJoined] = useState(false);
const { userCount, match, restaurants } = useRoomPolling(roomId); const { userCount, match, noMatch, restaurants, mutate } =
useRoomPolling(roomId);
useEffect(() => { useEffect(() => {
const id = getUserId(); const id = getUserId();
@@ -27,6 +28,11 @@ export default function RoomPage() {
}).then(() => setJoined(true)); }).then(() => setJoined(true));
}, [roomId]); }, [roomId]);
const handleReset = useCallback(async () => {
await fetch(`/api/room/${roomId}/reset`, { method: "POST" });
await mutate();
}, [roomId, mutate]);
const ready = joined && userId && restaurants.length > 0; const ready = joined && userId && restaurants.length > 0;
if (!ready) { if (!ready) {
@@ -46,6 +52,8 @@ export default function RoomPage() {
roomId={roomId} roomId={roomId}
userId={userId} userId={userId}
matchedRestaurantId={match} matchedRestaurantId={match}
noMatch={noMatch}
onReset={handleReset}
/> />
</div> </div>
); );
+1 -1
View File
@@ -13,7 +13,7 @@ import { Restaurant } from "@/types";
interface MatchResultProps { interface MatchResultProps {
restaurant: Restaurant; restaurant: Restaurant;
onReset: () => void; onReset: () => Promise<void>;
} }
function buildNavUrl(restaurant: Restaurant): string { function buildNavUrl(restaurant: Restaurant): string {
+54 -22
View File
@@ -1,17 +1,20 @@
"use client"; "use client";
import { useState, useCallback, useRef, useEffect } from "react"; import { useState, useCallback, useRef, useEffect } from "react";
import { AnimatePresence } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import SwipeableCard from "./SwipeableCard"; import SwipeableCard from "./SwipeableCard";
import ActionButtons from "./ActionButtons"; import ActionButtons from "./ActionButtons";
import MatchResult from "./MatchResult"; import MatchResult from "./MatchResult";
import { Restaurant, SwipeDirection } from "@/types"; import { Restaurant, SwipeDirection } from "@/types";
import { Frown, RotateCcw } from "lucide-react";
interface SwipeDeckProps { interface SwipeDeckProps {
restaurants: Restaurant[]; restaurants: Restaurant[];
roomId: string; roomId: string;
userId: string; userId: string;
matchedRestaurantId: string | null; matchedRestaurantId: string | null;
noMatch: boolean;
onReset: () => Promise<void>;
} }
export default function SwipeDeck({ export default function SwipeDeck({
@@ -19,20 +22,23 @@ export default function SwipeDeck({
roomId, roomId,
userId, userId,
matchedRestaurantId, matchedRestaurantId,
noMatch,
onReset,
}: SwipeDeckProps) { }: SwipeDeckProps) {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [showMatch, setShowMatch] = useState(false); const [showMatch, setShowMatch] = useState(false);
const [localMatchId, setLocalMatchId] = useState<string | null>(null); const [localMatchId, setLocalMatchId] = useState<string | null>(null);
const [resetting, setResetting] = useState(false);
const swipeFnRef = useRef<((direction: SwipeDirection) => void) | null>(null); const swipeFnRef = useRef<((direction: SwipeDirection) => void) | null>(null);
const swipingRef = useRef(false); const swipingRef = useRef(false);
const resolvedMatchId = matchedRestaurantId ?? localMatchId; const resolvedMatchId = matchedRestaurantId ?? localMatchId;
useEffect(() => { useEffect(() => {
if (matchedRestaurantId != null && !showMatch) { if (resolvedMatchId != null && !showMatch) {
setShowMatch(true); setShowMatch(true);
} }
}, [matchedRestaurantId, showMatch]); }, [resolvedMatchId, showMatch]);
const registerSwipe = useCallback( const registerSwipe = useCallback(
(fn: (direction: SwipeDirection) => void) => { (fn: (direction: SwipeDirection) => void) => {
@@ -70,15 +76,9 @@ export default function SwipeDeck({
const nextIndex = currentIndex + 1; const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex); setCurrentIndex(nextIndex);
swipeFnRef.current = null; swipeFnRef.current = null;
if (nextIndex >= restaurants.length && !resolvedMatchId) {
setTimeout(() => {
if (!showMatch) setShowMatch(true);
}, 300);
}
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[currentIndex, restaurants, roomId, userId, resolvedMatchId, showMatch], [currentIndex, restaurants, roomId, userId],
); );
const handleButtonAction = useCallback( const handleButtonAction = useCallback(
@@ -91,26 +91,32 @@ export default function SwipeDeck({
[], [],
); );
const handleReset = useCallback(() => { const handleReset = useCallback(async () => {
setCurrentIndex(0); setResetting(true);
setShowMatch(false); try {
setLocalMatchId(null); 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 const matchRestaurant = resolvedMatchId
? restaurants.find((r) => r.id === resolvedMatchId) ?? restaurants[0] ? restaurants.find((r) => r.id === resolvedMatchId) ?? null
: restaurants[0]; : null;
const showWaiting = const showWaiting = allSwiped && !resolvedMatchId && !noMatch;
currentIndex >= restaurants.length && !resolvedMatchId && !showMatch;
return ( return (
<> <>
<div className="relative flex flex-1 items-center justify-center px-4"> <div className="relative flex flex-1 items-center justify-center px-4">
<div className="relative h-[70vh] w-full max-w-sm"> <div className="relative h-[70vh] w-full max-w-sm">
{!resolvedMatchId && ( {!resolvedMatchId && !noMatch && (
<AnimatePresence> <AnimatePresence>
{restaurants.map((restaurant, index) => { {restaurants.map((restaurant, index) => {
if (index < currentIndex || index > currentIndex + 1) if (index < currentIndex || index > currentIndex + 1)
@@ -135,10 +141,36 @@ export default function SwipeDeck({
<p className="text-sm text-zinc-400">...</p> <p className="text-sm text-zinc-400">...</p>
</div> </div>
)} )}
{noMatch && !showMatch && (
<motion.div
className="flex h-full flex-col items-center justify-center gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-zinc-100">
<Frown size={32} className="text-zinc-400" />
</div>
<div className="text-center">
<p className="text-lg font-bold text-zinc-700"></p>
<p className="mt-1 text-sm text-zinc-400">
</p>
</div>
<button
onClick={handleReset}
disabled={resetting}
className="mt-2 flex items-center gap-2 rounded-xl bg-emerald-500 px-6 py-2.5 text-sm font-bold text-white shadow-md shadow-emerald-200 transition-colors hover:bg-emerald-600 disabled:opacity-50"
>
<RotateCcw size={16} className={resetting ? "animate-spin" : ""} />
{resetting ? "重置中..." : "再来一轮"}
</button>
</motion.div>
)}
</div> </div>
</div> </div>
<ActionButtons onAction={handleButtonAction} disabled={isDone} /> <ActionButtons onAction={handleButtonAction} disabled={isDone || noMatch} />
{showMatch && matchRestaurant && ( {showMatch && matchRestaurant && (
<MatchResult restaurant={matchRestaurant} onReset={handleReset} /> <MatchResult restaurant={matchRestaurant} onReset={handleReset} />
+16 -2
View File
@@ -1,22 +1,36 @@
"use client"; "use client";
import useSWR from "swr"; import useSWR from "swr";
import { useRef } from "react";
import { RoomStatus } from "@/types"; import { RoomStatus } from "@/types";
const fetcher = (url: string) => fetch(url).then((r) => r.json()); const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function useRoomPolling(roomId: string) { export function useRoomPolling(roomId: string) {
const { data, error, isLoading } = useSWR<RoomStatus>( const settled = useRef(false);
const { data, error, isLoading, mutate } = useSWR<RoomStatus>(
`/api/room/${roomId}`, `/api/room/${roomId}`,
fetcher, 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 { return {
userCount: data?.userCount ?? 0, userCount: data?.userCount ?? 0,
match: data?.match ?? null, match: data?.match ?? null,
noMatch: data?.noMatch ?? false,
restaurants: data?.restaurants ?? [], restaurants: data?.restaurants ?? [],
isLoading, isLoading,
error, error,
mutate,
}; };
} }
+33 -7
View File
@@ -5,6 +5,7 @@ export interface RoomData {
users: string[]; users: string[];
restaurants: Restaurant[]; restaurants: Restaurant[];
likes: Record<string, string[]>; likes: Record<string, string[]>;
swipeCounts: Record<string, number>;
match: string | null; match: string | null;
} }
@@ -12,11 +13,22 @@ function generateRoomId(): string {
return String(Math.floor(1000 + Math.random() * 9000)); return String(Math.floor(1000 + Math.random() * 9000));
} }
function normalize(raw: Partial<RoomData>): 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<string> { export async function createRoom(restaurants: Restaurant[]): Promise<string> {
const data: RoomData = { const data: RoomData = {
users: [], users: [],
restaurants, restaurants,
likes: {}, likes: {},
swipeCounts: {},
match: null, match: null,
}; };
@@ -47,15 +59,29 @@ export async function getRoomData(
): Promise<RoomData | null> { ): Promise<RoomData | null> {
const room = await prisma.room.findUnique({ where: { id: roomId } }); const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) return null; 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, roomId: string,
data: RoomData, updater: (data: RoomData) => RoomData,
): Promise<void> { ): Promise<RoomData | null> {
await prisma.room.update({ return prisma.$transaction(async (tx) => {
where: { id: roomId }, const room = await tx.room.findUnique({ where: { id: roomId } });
data: { data: JSON.stringify(data) }, 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;
}); });
} }
+1
View File
@@ -19,5 +19,6 @@ export interface RoomStatus {
roomId: string; roomId: string;
userCount: number; userCount: number;
match: string | null; match: string | null;
noMatch: boolean;
restaurants: Restaurant[]; restaurants: Restaurant[];
} }