fix: unify panic room code format and validate room join id

This commit is contained in:
2026-03-03 12:04:00 +08:00
parent 4f4220652e
commit f3d8a58603
7 changed files with 277 additions and 15 deletions
+12 -2
View File
@@ -74,11 +74,11 @@ describe("POST /api/room/[id]/join", () => {
it("returns 404 when room not found", async () => {
mockAtomicUpdate.mockResolvedValue(null);
const req = createRequest("/api/room/NONEXIST/join", {
const req = createRequest("/api/room/ABC123/join", {
method: "POST",
body: { userId: "user-1" },
});
const ctx = createRouteContext({ id: "NONEXIST" });
const ctx = createRouteContext({ id: "ABC123" });
const res = await POST(req, ctx);
expect(res.status).toBe(404);
});
@@ -92,4 +92,14 @@ describe("POST /api/room/[id]/join", () => {
const res = await POST(req, ctx);
expect(res.status).toBe(401);
});
it("returns 400 when room id format is invalid", async () => {
const req = createRequest("/api/room/1234/join", {
method: "POST",
body: { userId: "user-1" },
});
const ctx = createRouteContext({ id: "1234" });
const res = await POST(req, ctx);
expect(res.status).toBe(400);
});
});
+5 -3
View File
@@ -2,14 +2,16 @@ import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/roomRepository";
import { notify } from "@/lib/roomEvents";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
import { validatePanicRoomId } from "@/lib/validation";
export const POST = apiHandler(async (req, { params }) => {
const { id } = await params;
const roomId = validatePanicRoomId(id);
const { userId } = await req.json();
requireUserId(userId);
const updated = await atomicUpdateRoom(id, (data) => {
const updated = await atomicUpdateRoom(roomId, (data) => {
if (data.kickedUsers.includes(userId)) {
throw new ApiError("你已被移出该房间", 403);
}
@@ -24,10 +26,10 @@ export const POST = apiHandler(async (req, { params }) => {
if (!updated) throw new ApiError("房间不存在或已过期", 404);
notify(id);
notify(roomId);
return NextResponse.json({
roomId: id,
roomId,
userCount: updated.users.length,
});
});
+17
View File
@@ -58,6 +58,7 @@ const mockFetch = vi.fn().mockResolvedValue({
vi.stubGlobal("fetch", mockFetch);
import PanicPage from "./page";
import { joinRoom } from "@/lib/room";
const toastCtx: ToastContextValue = { show: vi.fn() };
@@ -97,4 +98,20 @@ describe("PanicPage", () => {
renderPage();
expect(screen.getAllByText("餐厅").length).toBeGreaterThanOrEqual(1);
});
it("normalizes room code as 6-char alphanumeric when joining", async () => {
renderPage();
const input = screen.getByPlaceholderText("输入 6 位房间号");
fireEvent.change(input, { target: { value: "ab12-cd!" } });
expect(input).toHaveValue("AB12CD");
const submitButton = screen.getByLabelText("加入房间");
expect(submitButton).not.toBeDisabled();
fireEvent.click(submitButton);
await waitFor(() => {
expect(vi.mocked(joinRoom)).toHaveBeenCalledWith("AB12CD", "user-1");
});
});
});
+15 -10
View File
@@ -170,15 +170,16 @@ export default function PanicPage() {
const handleJoin = async (e: React.FormEvent) => {
e.preventDefault();
if (roomCode.length !== 4) {
setError("请输入 4 位房间号");
if (roomCode.length !== 6) {
setError("请输入 6 位房间号");
return;
}
setLoading(true);
setError("");
try {
await joinRoom(roomCode, getUserId());
router.push(`/room/${roomCode}`);
const normalizedRoomCode = roomCode.toUpperCase();
await joinRoom(normalizedRoomCode, getUserId());
router.push(`/room/${normalizedRoomCode}`);
} catch (e) {
console.error("PanicPage: handleJoin failed:", e);
setError("房间不存在,请检查房间号");
@@ -562,13 +563,17 @@ export default function PanicPage() {
<form onSubmit={handleJoin} className="flex gap-2">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
placeholder="输入 4 位房间号"
inputMode="text"
pattern="[A-Za-z0-9]*"
maxLength={6}
placeholder="输入 6 位房间号"
value={roomCode}
onChange={(e) => {
setRoomCode(e.target.value.replace(/\D/g, "").slice(0, 4));
const normalized = e.target.value
.toUpperCase()
.replace(/[^A-Z0-9]/g, "")
.slice(0, 6);
setRoomCode(normalized);
setError("");
}}
disabled={loading}
@@ -576,7 +581,7 @@ export default function PanicPage() {
/>
<button
type="submit"
disabled={loading || roomCode.length !== 4}
disabled={loading || roomCode.length !== 6}
aria-label="加入房间"
className="flex h-11 w-11 items-center justify-center rounded-xl bg-elevated text-secondary ring-1 ring-subtle transition-colors hover:bg-subtle disabled:opacity-30"
>
+14
View File
@@ -7,6 +7,7 @@ import {
validateIdeaContent,
validateRoomName,
requireString,
validatePanicRoomId,
} from "@/lib/validation";
describe("validateUsername", () => {
@@ -125,3 +126,16 @@ describe("requireString", () => {
expect(() => requireString(123, "字段")).toThrow(ApiError);
});
});
describe("validatePanicRoomId", () => {
it("accepts 6-char alphanumeric room id and normalizes uppercase", () => {
expect(validatePanicRoomId("ab12cd")).toBe("AB12CD");
expect(validatePanicRoomId("ROOM01")).toBe("ROOM01");
});
it("rejects invalid room id format", () => {
expect(() => validatePanicRoomId("1234")).toThrow(ApiError);
expect(() => validatePanicRoomId("1234567")).toThrow(ApiError);
expect(() => validatePanicRoomId("12-45a")).toThrow(ApiError);
});
});
+13
View File
@@ -48,3 +48,16 @@ export function requireString(value: unknown, fieldName: string): string {
}
return value;
}
const PANIC_ROOM_ID_REGEX = /^[A-Z0-9]{6}$/;
export function validatePanicRoomId(raw: unknown): string {
if (!raw || typeof raw !== "string") {
throw new ApiError("roomId 不能为空");
}
const roomId = raw.trim().toUpperCase();
if (!PANIC_ROOM_ID_REGEX.test(roomId)) {
throw new ApiError("房间号格式无效,应为 6 位字母数字", 400);
}
return roomId;
}