From 8c0d89af6d43e57181f190edc2c2ac5f3dffe007 Mon Sep 17 00:00:00 2001 From: kurihada Date: Tue, 24 Feb 2026 19:51:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=20SSE=20=E6=9B=BF=E4=BB=A3=20?= =?UTF-8?q?SWR=20=E8=BD=AE=E8=AF=A2=EF=BC=8C=E5=AE=9E=E7=8E=B0=E6=88=BF?= =?UTF-8?q?=E9=97=B4=E7=8A=B6=E6=80=81=E5=AE=9E=E6=97=B6=E6=8E=A8=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSE 断连时自动降级为 2s 轮询,重连后切回 SSE。 --- src/app/api/room/[id]/events/route.ts | 76 +++++++++++++++++++++++++++ src/app/api/room/[id]/join/route.ts | 3 ++ src/app/api/room/[id]/reset/route.ts | 3 ++ src/app/api/room/[id]/route.ts | 63 ++-------------------- src/app/api/room/[id]/swipe/route.ts | 3 ++ src/app/api/room/[id]/undo/route.ts | 3 ++ src/hooks/useRoomPolling.ts | 51 +++++++++++++----- src/lib/buildRoomStatus.ts | 69 ++++++++++++++++++++++++ src/lib/roomEvents.ts | 23 ++++++++ 9 files changed, 223 insertions(+), 71 deletions(-) create mode 100644 src/app/api/room/[id]/events/route.ts create mode 100644 src/lib/buildRoomStatus.ts create mode 100644 src/lib/roomEvents.ts diff --git a/src/app/api/room/[id]/events/route.ts b/src/app/api/room/[id]/events/route.ts new file mode 100644 index 0000000..60a4348 --- /dev/null +++ b/src/app/api/room/[id]/events/route.ts @@ -0,0 +1,76 @@ +import { buildRoomStatus } from "@/lib/buildRoomStatus"; +import { subscribe } from "@/lib/roomEvents"; + +export const dynamic = "force-dynamic"; + +export async function GET( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + const send = (obj: object) => { + try { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(obj)}\n\n`), + ); + } catch { + /* controller already closed */ + } + }; + + let alive = true; + + (async () => { + const status = await buildRoomStatus(id); + if (!status) { + send({ error: "room_not_found" }); + controller.close(); + return; + } + if (alive) send(status); + })(); + + const unsubscribe = subscribe(id, async () => { + if (!alive) return; + try { + const status = await buildRoomStatus(id); + if (status && alive) send(status); + } catch { + /* ignore transient read errors */ + } + }); + + const heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(": heartbeat\n\n")); + } catch { + clearInterval(heartbeat); + } + }, 30_000); + + req.signal.addEventListener("abort", () => { + alive = false; + unsubscribe(); + clearInterval(heartbeat); + try { + controller.close(); + } catch { + /* already closed */ + } + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/src/app/api/room/[id]/join/route.ts b/src/app/api/room/[id]/join/route.ts index f4e32e4..17091f0 100644 --- a/src/app/api/room/[id]/join/route.ts +++ b/src/app/api/room/[id]/join/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { atomicUpdateRoom } from "@/lib/store"; +import { notify } from "@/lib/roomEvents"; export async function POST( req: Request, @@ -27,6 +28,8 @@ export async function POST( ); } + notify(id); + return NextResponse.json({ roomId: id, userCount: updated.users.length, diff --git a/src/app/api/room/[id]/reset/route.ts b/src/app/api/room/[id]/reset/route.ts index ff84a7b..a5272fd 100644 --- a/src/app/api/room/[id]/reset/route.ts +++ b/src/app/api/room/[id]/reset/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { atomicUpdateRoom } from "@/lib/store"; +import { notify } from "@/lib/roomEvents"; export async function POST( req: Request, @@ -36,6 +37,8 @@ export async function POST( ); } + notify(id); + return NextResponse.json({ ok: true }); } catch (e) { console.error("Failed to reset room:", e); diff --git a/src/app/api/room/[id]/route.ts b/src/app/api/room/[id]/route.ts index b55f057..7c2a1f5 100644 --- a/src/app/api/room/[id]/route.ts +++ b/src/app/api/room/[id]/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; -import { getRoomData } from "@/lib/store"; -import type { MatchType } from "@/types"; +import { buildRoomStatus } from "@/lib/buildRoomStatus"; export async function GET( _req: Request, @@ -9,60 +8,16 @@ export async function GET( const { id } = await params; try { - const data = await getRoomData(id); + const status = await buildRoomStatus(id); - if (!data) { + if (!status) { return NextResponse.json( { error: "房间不存在或已过期" }, { status: 404 }, ); } - const total = data.restaurants.length; - const allFinished = - data.users.length > 0 && - data.users.every((u) => (data.swipeCounts[u] ?? 0) >= total); - - let match = data.match; - let matchType: MatchType = null; - let matchLikes = 0; - let runnerUps: { id: string; likes: number }[] = []; - - if (match) { - matchType = "unanimous"; - matchLikes = data.users.length; - } else if (allFinished && data.restaurants.length > 0) { - const ranked = rankRestaurants(data.likes, data.restaurants); - if (ranked[0].likes > 0) { - match = ranked[0].id; - matchType = "best"; - matchLikes = ranked[0].likes; - runnerUps = ranked.slice(1, 3).filter((r) => r.likes > 0); - } else { - match = ranked[0].id; - matchType = "no_match"; - matchLikes = 0; - } - } - - const likeCounts: Record = {}; - for (const [rid, users] of Object.entries(data.likes)) { - if (users.length > 0) { - likeCounts[rid] = users.length; - } - } - - return NextResponse.json({ - roomId: id, - userCount: data.users.length, - match, - matchType, - matchLikes, - runnerUps, - likeCounts, - swipeCounts: data.swipeCounts, - restaurants: data.restaurants, - }); + return NextResponse.json(status); } catch (e) { console.error("Failed to get room:", e); return NextResponse.json( @@ -71,13 +26,3 @@ export async function GET( ); } } - -function rankRestaurants( - likes: Record, - restaurants: { id: string; rating: number }[], -): { id: string; likes: number }[] { - return restaurants - .map((r) => ({ id: r.id, likes: likes[r.id]?.length ?? 0, rating: r.rating })) - .sort((a, b) => b.likes - a.likes || b.rating - a.rating) - .map(({ id, likes: l }) => ({ id, likes: l })); -} diff --git a/src/app/api/room/[id]/swipe/route.ts b/src/app/api/room/[id]/swipe/route.ts index fd02522..ffdedbd 100644 --- a/src/app/api/room/[id]/swipe/route.ts +++ b/src/app/api/room/[id]/swipe/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { atomicUpdateRoom } from "@/lib/store"; +import { notify } from "@/lib/roomEvents"; export async function POST( req: Request, @@ -52,6 +53,8 @@ export async function POST( ); } + notify(id); + return NextResponse.json({ match: updated.match, likeCount: updated.likes[rid]?.length ?? 0, diff --git a/src/app/api/room/[id]/undo/route.ts b/src/app/api/room/[id]/undo/route.ts index eb4636e..16ca7c3 100644 --- a/src/app/api/room/[id]/undo/route.ts +++ b/src/app/api/room/[id]/undo/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { atomicUpdateRoom } from "@/lib/store"; +import { notify } from "@/lib/roomEvents"; export async function POST( req: Request, @@ -46,6 +47,8 @@ export async function POST( ); } + notify(id); + return NextResponse.json({ ok: true }); } catch (e) { console.error("Failed to undo swipe:", e); diff --git a/src/hooks/useRoomPolling.ts b/src/hooks/useRoomPolling.ts index 6c6a0ac..87f5178 100644 --- a/src/hooks/useRoomPolling.ts +++ b/src/hooks/useRoomPolling.ts @@ -1,28 +1,55 @@ "use client"; import useSWR from "swr"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { RoomStatus } from "@/types"; const fetcher = (url: string) => fetch(url).then((r) => r.json()); export function useRoomPolling(roomId: string) { - const settled = useRef(false); - const { data, error, isLoading, mutate } = useSWR( `/api/room/${roomId}`, fetcher, - { - refreshInterval: settled.current ? 0 : 1500, - revalidateOnFocus: true, - }, + { revalidateOnFocus: true }, ); - if (data?.match != null) { - settled.current = true; - } else { - settled.current = false; - } + const fallbackRef = useRef | null>(null); + + useEffect(() => { + const es = new EventSource(`/api/room/${roomId}/events`); + + es.onmessage = (e) => { + try { + const parsed = JSON.parse(e.data); + if (parsed.roomId) { + mutate(parsed, { revalidate: false }); + } + } catch { + /* malformed message */ + } + }; + + es.onerror = () => { + if (!fallbackRef.current) { + fallbackRef.current = setInterval(() => mutate(), 2000); + } + }; + + es.onopen = () => { + if (fallbackRef.current) { + clearInterval(fallbackRef.current); + fallbackRef.current = null; + } + }; + + return () => { + es.close(); + if (fallbackRef.current) { + clearInterval(fallbackRef.current); + fallbackRef.current = null; + } + }; + }, [roomId, mutate]); return { userCount: data?.userCount ?? 0, diff --git a/src/lib/buildRoomStatus.ts b/src/lib/buildRoomStatus.ts new file mode 100644 index 0000000..9991f14 --- /dev/null +++ b/src/lib/buildRoomStatus.ts @@ -0,0 +1,69 @@ +import { getRoomData } from "./store"; +import type { RoomStatus, MatchType } from "@/types"; + +export async function buildRoomStatus( + roomId: string, +): Promise { + const data = await getRoomData(roomId); + if (!data) return null; + + const total = data.restaurants.length; + const allFinished = + data.users.length > 0 && + data.users.every((u) => (data.swipeCounts[u] ?? 0) >= total); + + let match = data.match; + let matchType: MatchType = null; + let matchLikes = 0; + let runnerUps: { id: string; likes: number }[] = []; + + if (match) { + matchType = "unanimous"; + matchLikes = data.users.length; + } else if (allFinished && data.restaurants.length > 0) { + const ranked = rankRestaurants(data.likes, data.restaurants); + if (ranked[0].likes > 0) { + match = ranked[0].id; + matchType = "best"; + matchLikes = ranked[0].likes; + runnerUps = ranked.slice(1, 3).filter((r) => r.likes > 0); + } else { + match = ranked[0].id; + matchType = "no_match"; + matchLikes = 0; + } + } + + const likeCounts: Record = {}; + for (const [rid, users] of Object.entries(data.likes)) { + if (users.length > 0) { + likeCounts[rid] = users.length; + } + } + + return { + roomId, + userCount: data.users.length, + match, + matchType, + matchLikes, + runnerUps, + likeCounts, + swipeCounts: data.swipeCounts, + restaurants: data.restaurants, + }; +} + +function rankRestaurants( + likes: Record, + restaurants: { id: string; rating: number }[], +): { id: string; likes: number }[] { + return restaurants + .map((r) => ({ + id: r.id, + likes: likes[r.id]?.length ?? 0, + rating: r.rating, + })) + .sort((a, b) => b.likes - a.likes || b.rating - a.rating) + .map(({ id, likes: l }) => ({ id, likes: l })); +} diff --git a/src/lib/roomEvents.ts b/src/lib/roomEvents.ts new file mode 100644 index 0000000..14a6fed --- /dev/null +++ b/src/lib/roomEvents.ts @@ -0,0 +1,23 @@ +type Listener = () => void; + +const listeners = new Map>(); + +export function subscribe(roomId: string, listener: Listener): () => void { + if (!listeners.has(roomId)) { + listeners.set(roomId, new Set()); + } + const set = listeners.get(roomId)!; + set.add(listener); + + return () => { + set.delete(listener); + if (set.size === 0) listeners.delete(roomId); + }; +} + +export function notify(roomId: string) { + const set = listeners.get(roomId); + if (set) { + for (const fn of set) fn(); + } +}