fix: unify panic room code format and validate room join id
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
# 项目全面审查报告(2026-03-03)
|
||||
|
||||
## 审查范围与方法
|
||||
- 范围:`src/`、`prisma/`、`Dockerfile`、`Jenkinsfile`、`README.md`、测试与构建脚本。
|
||||
- 方式:静态审查 + 命令基线 + 关键链路手工走读(Panic 模式、房间投票、盲盒计划)。
|
||||
- 执行过的基线命令:
|
||||
- `npm run lint`
|
||||
- `npm test`
|
||||
- `npx tsc --noEmit`
|
||||
- `npm run test:coverage`
|
||||
- `npm run build`(当前环境因无法访问 Google Fonts 未完全通过)
|
||||
|
||||
## 结论概览
|
||||
- 发现高优问题(P0/P1):5 个
|
||||
- 发现中优问题(P2):6 个
|
||||
- 主要风险类型:业务功能中断、状态数据污染、鉴权边界不完整、敏感信息暴露、工程质量门禁缺失。
|
||||
|
||||
---
|
||||
|
||||
## 关键问题(按优先级)
|
||||
|
||||
### P0-1 Panic 模式“手动加入房间”输入规则与后端房间 ID 规则不一致(功能性缺陷)【已完成】
|
||||
- 修复状态:✅ 已完成(2026-03-03)
|
||||
- 修复内容:
|
||||
- 前端改为 6 位字母数字房间号输入,统一转大写并过滤非法字符;
|
||||
- 后端 `POST /api/room/[id]/join` 增加房间号格式校验与标准化;
|
||||
- 补充校验与页面行为测试用例。
|
||||
- 证据:
|
||||
- `src/app/panic/page.tsx:173`(要求长度必须为 4)
|
||||
- `src/app/panic/page.tsx:567`(`maxLength={4}`)
|
||||
- `src/app/panic/page.tsx:566`(仅允许数字)
|
||||
- `src/lib/roomRepository.ts:21`(实际房间 ID 生成规则为 6 位,且含字母)
|
||||
- 影响:
|
||||
- 用户无法通过手输房间号加入大多数房间(尤其是含字母的房间号)。
|
||||
- 直接影响核心转化路径(邀请后加入房间)。
|
||||
- 建议:
|
||||
- 前端加入输入改为 6 位字母数字(与 `ROOM_ID_CHARS` 一致);
|
||||
- 后端补充房间号格式校验与明确错误提示;
|
||||
- 增加 E2E 用例覆盖“手输邀请码加入”。
|
||||
|
||||
### P0-2 投票接口未校验 `restaurantId` 是否属于房间候选列表(可污染房间状态)
|
||||
- 证据:
|
||||
- `src/app/api/room/[id]/swipe/route.ts:25`(仅查 index)
|
||||
- `src/app/api/room/[id]/swipe/route.ts:32-42`(即使 `restaurantIndex === -1` 仍可写入 `likes` 并可能设置 `match`)
|
||||
- 影响:
|
||||
- 客户端可提交伪造 `restaurantId`,导致房间匹配结果异常或不可展示;
|
||||
- 状态污染会影响所有成员的结果一致性。
|
||||
- 建议:
|
||||
- 在进入投票逻辑前强制校验 `restaurantIndex >= 0`,否则返回 400;
|
||||
- 对 `undo/reset` 等相关接口同步增加 ID 合法性校验;
|
||||
- 增加“非法 restaurantId”单测。
|
||||
|
||||
### P1-1 `suggest-item` 接口缺失鉴权(可被匿名滥用,产生外部 API 成本)
|
||||
- 证据:
|
||||
- `src/app/api/blindbox/plan/suggest-item/route.ts:6-11`(无 `getAuthUserId` / 无 membership 校验)
|
||||
- 影响:
|
||||
- 任意匿名请求可触发 AI + 地图查询,存在成本与滥用风险。
|
||||
- 建议:
|
||||
- 至少要求登录态;更稳妥是同时校验房间成员身份(若与房间上下文绑定);
|
||||
- 配合限流(IP + 用户维度)。
|
||||
|
||||
### P1-2 API 错误响应直接回传内部异常细节(信息泄露)
|
||||
- 证据:
|
||||
- `src/lib/api.ts:63-65`(500 响应包含 `ErrorName: message`)
|
||||
- 影响:
|
||||
- 暴露内部实现信息、库错误细节、潜在环境配置线索;
|
||||
- 提升攻击者探测效率。
|
||||
- 建议:
|
||||
- 客户端统一返回泛化错误文案;
|
||||
- 详细错误仅记录在服务端日志(可加 requestId 关联)。
|
||||
|
||||
### P1-3 CI 配置中存在敏感信息硬编码
|
||||
- 证据:
|
||||
- `Jenkinsfile:6`(地图 key 常量)
|
||||
- `Jenkinsfile:11`(固定触发 token)
|
||||
- 影响:
|
||||
- 密钥泄露与流水线被外部触发的风险增加;
|
||||
- 密钥轮转和环境隔离困难。
|
||||
- 建议:
|
||||
- 全部迁移到凭据系统(Jenkins Credentials / Secret Manager);
|
||||
- 触发 token 改为密文凭据并定期轮换。
|
||||
|
||||
---
|
||||
|
||||
## 中优问题(P2)
|
||||
|
||||
### P2-1 SSE 成员校验逻辑未真正启用(前端未传 `userId`)
|
||||
- 证据:
|
||||
- `src/app/api/room/[id]/events/route.ts:14-18`(仅在 query 存在 `userId` 时校验)
|
||||
- `src/hooks/useRoomPolling.ts:32`(实际建立 SSE 连接时未携带 `userId`)
|
||||
- 影响:
|
||||
- 只要知道房间 ID 即可订阅房间流事件(当前实现下)。
|
||||
- 建议:
|
||||
- 改为服务端基于认证态校验,或强制 query/body 校验成员身份;
|
||||
- 前端补齐身份参数并确保不可伪造(推荐 token/cookie 方案)。
|
||||
|
||||
### P2-2 “接受计划”前端逻辑乐观更新过早,且未检查 `res.ok`
|
||||
- 证据:
|
||||
- `src/hooks/useBlindboxPlan.ts:227-240`(先 `setPlanAccepted(true)`,请求后不判断 `res.ok`)
|
||||
- 影响:
|
||||
- 后端失败时前端仍显示“已接受”,形成状态不一致。
|
||||
- 建议:
|
||||
- 先请求成功再更新 `planAccepted/activeContract`;
|
||||
- 失败回滚并展示错误信息。
|
||||
|
||||
### P2-3 TypeScript 基线不通过(测试代码类型漂移)
|
||||
- 证据:
|
||||
- `npx tsc --noEmit` 当前报多个错误(测试辅助类型、mock 签名、类型定义漂移等)。
|
||||
- 影响:
|
||||
- 类型系统对回归的兜底失效;重构时风险上升。
|
||||
- 建议:
|
||||
- 将 `tsc --noEmit` 纳入 CI 必跑;
|
||||
- 优先修复测试目录类型错误,确保类型门禁恢复。
|
||||
|
||||
### P2-4 Lint 存在阻塞错误(React hooks 新规则触发)
|
||||
- 证据:
|
||||
- `npm run lint`:10 error / 32 warning。
|
||||
- 代表性问题:
|
||||
- `src/components/SwipeableCard.tsx:81`(render 阶段注册副作用)
|
||||
- `src/components/GlobalUserBadge.tsx:23`(effect 内同步 setState)
|
||||
- `src/components/PageTransition.tsx:14`(render 期间 ref 访问)
|
||||
- `src/hooks/useGeolocation.ts:67`(effect 内直接触发状态写入路径)
|
||||
- 影响:
|
||||
- 渲染稳定性与性能风险;后续升级 React/Next 成本上升。
|
||||
- 建议:
|
||||
- 先清理 error 级规则,再统一处理 warning;
|
||||
- 针对 hooks 规则建立最小回归测试。
|
||||
|
||||
### P2-5 定时器清理不完整(潜在内存泄漏/卸载后状态写入)
|
||||
- 证据:
|
||||
- `src/hooks/useBlindboxIdeas.ts:52` + `:129`(保存定时器,但无统一 cleanup)
|
||||
- `src/hooks/useBlindboxDraw.ts:25` + `:38`(定时器与动画回调未见 unmount 清理)
|
||||
- 影响:
|
||||
- 页面切换或快速操作下可能出现卸载后 setState、额外渲染噪音。
|
||||
- 建议:
|
||||
- 为上述 hook 增加 `useEffect` cleanup 清理 `timersRef`;
|
||||
- confetti 动画增加销毁标志与取消逻辑。
|
||||
|
||||
### P2-6 构建/测试门禁链路不完整
|
||||
- 证据:
|
||||
- `Jenkinsfile` 当前仅构建和部署,无 `lint/test/tsc` 阶段。
|
||||
- `npm run test:coverage` 缺少 `@vitest/coverage-v8`。
|
||||
- `npm test` 虽通过,但出现多处 `act(...)` 警告。
|
||||
- 影响:
|
||||
- 回归问题可能绕过 CI 直接进入部署。
|
||||
- 建议:
|
||||
- CI 最少增加:`npm run lint`、`npx tsc --noEmit`、`npm test`;
|
||||
- 补齐 coverage 依赖并设定最低阈值;
|
||||
- 修复 `act(...)` 警告提升测试可信度。
|
||||
|
||||
---
|
||||
|
||||
## 可重构/可优化项(非阻塞,但收益高)
|
||||
|
||||
### R1 计划查询存在 N+1 查询模式
|
||||
- 证据:
|
||||
- `src/lib/planQueries.ts:30-36`、`63-68`(每条计划单独查房间)
|
||||
- 建议:
|
||||
- 使用 `include` / 批量映射一次性拉取房间信息,减少 DB 往返。
|
||||
|
||||
### R2 `atomicUpdateRoom` 对 likes 的“全删全建”策略成本较高
|
||||
- 证据:
|
||||
- `src/lib/roomRepository.ts:185-195`
|
||||
- 建议:
|
||||
- 按增量 diff 更新(新增/删除差集)替代全量重建;
|
||||
- 对高频路径(swipe/undo)优先优化。
|
||||
|
||||
### R3 请求参数契约不统一(前端仍大量发送已废弃 `userId`)
|
||||
- 证据:
|
||||
- 例如 `src/hooks/useBlindboxIdeas.ts:57/71/104/...`、`src/hooks/useBlindboxPlan.ts:50/68/...`
|
||||
- 建议:
|
||||
- 明确“鉴权由 cookie/token 提供”,清理冗余 `userId` 参数;
|
||||
- 用 Zod schema 统一约束(`src/lib/schemas/requests.ts` 目前未形成全链路使用)。
|
||||
|
||||
### R4 统一 API 调用层(减少重复 fetch + 错误处理分散)
|
||||
- 现状:
|
||||
- 客户端很多模块各自拼接 URL、手写错误分支。
|
||||
- 建议:
|
||||
- 为业务 API 建立 typed client(含统一重试、错误映射、鉴权处理);
|
||||
- 与 SWR key 规范化一起推进。
|
||||
|
||||
---
|
||||
|
||||
## 基线执行结果(本次审查)
|
||||
- `npm run lint`:失败,`10 errors / 32 warnings`。
|
||||
- `npm test`:通过,`53 files / 329 tests`,但有 `act(...)` 警告。
|
||||
- `npx tsc --noEmit`:失败(测试相关类型错误若干)。
|
||||
- `npm run test:coverage`:失败(缺少 `@vitest/coverage-v8`)。
|
||||
- `npm run build`:本地环境因无法访问 Google Fonts 失败(与当前受限网络环境相关)。
|
||||
|
||||
---
|
||||
|
||||
## 建议修复顺序(可执行)
|
||||
1. 先修 P0(房间号规则不一致、swipe 非法 ID 校验)。
|
||||
2. 再修 P1(`suggest-item` 鉴权、API 错误脱敏、CI 密钥治理)。
|
||||
3. 处理 P2 工程门禁(lint/tsc/CI),恢复“提交即验证”。
|
||||
4. 最后推进 R 类重构(N+1、增量更新、API client 统一)。
|
||||
|
||||
## 交付说明
|
||||
- 本文档为静态审查结论,未改动业务代码。
|
||||
- 建议下一步按“P0/P1 修复 PR + 回归测试补齐”方式分批落地。
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user