修复 SSE 成员校验未生效的问题

This commit is contained in:
2026-03-03 12:14:38 +08:00
parent 724be69c76
commit 67fdf7427a
5 changed files with 59 additions and 24 deletions
+7 -1
View File
@@ -103,7 +103,13 @@
## 中优问题(P2 ## 中优问题(P2
### P2-1 SSE 成员校验逻辑未真正启用(前端未传 `userId`) ### P2-1 SSE 成员校验逻辑未真正启用(前端未传 `userId`)【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
- `useRoomPolling` 改为显式接收 `userId`SSE 连接带上 `?userId=...`
- `room/[id]/page` 传入当前用户 ID
- `GET /api/room/[id]/events` 强制 `userId` 必填并做成员校验(无 userId 返回 401,非成员返回 403);
- 更新 hook 测试覆盖 SSE URL 与缺失 userId 场景。
- 证据: - 证据:
- `src/app/api/room/[id]/events/route.ts:14-18`(仅在 query 存在 `userId` 时校验) - `src/app/api/room/[id]/events/route.ts:14-18`(仅在 query 存在 `userId` 时校验)
- `src/hooks/useRoomPolling.ts:32`(实际建立 SSE 连接时未携带 `userId` - `src/hooks/useRoomPolling.ts:32`(实际建立 SSE 连接时未携带 `userId`
+18 -8
View File
@@ -12,15 +12,25 @@ export async function GET(
const url = new URL(req.url); const url = new URL(req.url);
const userId = url.searchParams.get("userId"); const userId = url.searchParams.get("userId");
if (!userId) {
return new Response(JSON.stringify({ error: "missing_user_id" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
if (userId) { const data = await getRoomData(id);
const data = await getRoomData(id); if (!data) {
if (data && !data.users.includes(userId)) { return new Response(JSON.stringify({ error: "room_not_found" }), {
return new Response(JSON.stringify({ error: "not_a_member" }), { status: 404,
status: 403, headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json" }, });
}); }
} if (!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();
+1 -1
View File
@@ -28,7 +28,7 @@ export default function RoomPage() {
const { const {
userCount, match, matchType, matchLikes, runnerUps, likeCounts, swipeCounts, userCount, match, matchType, matchLikes, runnerUps, likeCounts, swipeCounts,
restaurants, notFound, mutate, creatorId, locked, users, userProfiles, scene, restaurants, notFound, mutate, creatorId, locked, users, userProfiles, scene,
} = useRoomPolling(roomId); } = useRoomPolling(roomId, userId || undefined);
useEffect(() => { useEffect(() => {
if (!roomId) return; if (!roomId) return;
+24 -10
View File
@@ -1,15 +1,23 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react"; import { renderHook, waitFor, act } from "@testing-library/react";
let esInstances: { onmessage?: (e: MessageEvent) => void; onerror?: () => void; onopen?: () => void; close: ReturnType<typeof vi.fn> }[] = []; let esInstances: {
url: string;
onmessage?: (e: MessageEvent) => void;
onerror?: () => void;
onopen?: () => void;
close: ReturnType<typeof vi.fn>;
}[] = [];
class MockEventSource { class MockEventSource {
onmessage: ((e: MessageEvent) => void) | null = null; url: string;
onerror: (() => void) | null = null; onmessage: ((e: MessageEvent) => void) | undefined;
onopen: (() => void) | null = null; onerror: (() => void) | undefined;
onopen: (() => void) | undefined;
close = vi.fn(); close = vi.fn();
constructor(_url: string) { constructor(url: string) {
this.url = url;
esInstances.push(this); esInstances.push(this);
} }
} }
@@ -52,7 +60,7 @@ afterEach(() => {
describe("useRoomPolling", () => { describe("useRoomPolling", () => {
it("returns room data from SWR", () => { it("returns room data from SWR", () => {
const { result } = renderHook(() => useRoomPolling("ROOM01")); const { result } = renderHook(() => useRoomPolling("ROOM01", "user-1"));
expect(result.current.userCount).toBe(2); expect(result.current.userCount).toBe(2);
expect(result.current.users).toEqual(["user-1", "user-2"]); expect(result.current.users).toEqual(["user-1", "user-2"]);
@@ -60,12 +68,18 @@ describe("useRoomPolling", () => {
}); });
it("creates EventSource connection", () => { it("creates EventSource connection", () => {
renderHook(() => useRoomPolling("ROOM01")); renderHook(() => useRoomPolling("ROOM01", "user-1"));
expect(esInstances.length).toBeGreaterThan(0); expect(esInstances.length).toBeGreaterThan(0);
expect(esInstances[0].url).toContain("/api/room/ROOM01/events?userId=user-1");
});
it("does not create EventSource when userId is missing", () => {
renderHook(() => useRoomPolling("ROOM01"));
expect(esInstances.length).toBe(0);
}); });
it("returns defaults when no roomId", () => { it("returns defaults when no roomId", () => {
const { result } = renderHook(() => useRoomPolling(undefined)); const { result } = renderHook(() => useRoomPolling(undefined, "user-1"));
expect(result.current.userCount).toBe(2); expect(result.current.userCount).toBe(2);
}); });
@@ -78,12 +92,12 @@ describe("useRoomPolling", () => {
mutate: vi.fn(), mutate: vi.fn(),
} as never); } as never);
const { result } = renderHook(() => useRoomPolling("ROOM01")); const { result } = renderHook(() => useRoomPolling("ROOM01", "user-1"));
expect(result.current.notFound).toBe(true); expect(result.current.notFound).toBe(true);
}); });
it("cleans up EventSource on unmount", () => { it("cleans up EventSource on unmount", () => {
const { unmount } = renderHook(() => useRoomPolling("ROOM01")); const { unmount } = renderHook(() => useRoomPolling("ROOM01", "user-1"));
const es = esInstances[esInstances.length - 1]; const es = esInstances[esInstances.length - 1];
unmount(); unmount();
expect(es.close).toHaveBeenCalled(); expect(es.close).toHaveBeenCalled();
+9 -4
View File
@@ -10,7 +10,10 @@ function backoffMs(attempt: number): number {
return Math.min(2000 * Math.pow(2, attempt) + Math.random() * 500, 60_000); return Math.min(2000 * Math.pow(2, attempt) + Math.random() * 500, 60_000);
} }
export function useRoomPolling(roomId: string | undefined) { export function useRoomPolling(
roomId: string | undefined,
userId?: string,
) {
const { data, error, isLoading, mutate } = useSWR<RoomStatus>( const { data, error, isLoading, mutate } = useSWR<RoomStatus>(
roomId ? `/api/room/${roomId}` : null, roomId ? `/api/room/${roomId}` : null,
fetcher, fetcher,
@@ -28,8 +31,10 @@ export function useRoomPolling(roomId: string | undefined) {
const notFound = error?.message === "NOT_FOUND"; const notFound = error?.message === "NOT_FOUND";
useEffect(() => { useEffect(() => {
if (!roomId) return; if (!roomId || !userId) return;
const es = new EventSource(`/api/room/${roomId}/events`); const es = new EventSource(
`/api/room/${roomId}/events?userId=${encodeURIComponent(userId)}`,
);
const clearFallback = () => { const clearFallback = () => {
if (fallbackRef.current) { if (fallbackRef.current) {
@@ -68,7 +73,7 @@ export function useRoomPolling(roomId: string | undefined) {
es.close(); es.close();
clearFallback(); clearFallback();
}; };
}, [roomId, mutate]); }, [roomId, userId, mutate]);
return { return {
userCount: data?.userCount ?? 0, userCount: data?.userCount ?? 0,