diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md new file mode 100644 index 0000000..c527fdd --- /dev/null +++ b/REFACTOR_PLAN.md @@ -0,0 +1,209 @@ +# 项目代码质量全景分析 & 重构计划 + +> 生成时间: 2026-03-02 | 分析工具: Claude Code 7-agent 并行审查 + +## 模块评分总览 + +| # | 模块 | 评分 | 重构优先级 | 核心问题 | +|---|------|------|-----------|----------| +| 1 | 认证 & 用户 API | 7.5 | 高 | 无真正鉴权机制,userId 可伪造 | +| 2 | 房间投票 API | 7.0 | 中 | atomicUpdateRoom 并发 lost update | +| 3 | 盲盒系统 API | 7.0 | 中 | AI 错误静默吞掉,plan/route.ts 臃肿 | +| 4 | 位置服务 API | 6.5 | 中 | 高德 API 调用代码 4 处重复,无超时/缓存 | +| 5 | 前端页面 | 5.5 | 高 | blindbox/[code] 1300行 37个useState | +| 6 | 组件 & Hooks | 6.5 | 高 | ShareCard 三兄弟 60% 重复,BlindboxPlan 742行 | +| 7 | 基础设施 & Lib | 6.5 | 高 | Room JSON blob 模型、前后端代码边界模糊 | + +**全局平均: 6.6 / 10** + +--- + +## 跨模块共性问题 TOP 5 + +### 1. 无真正的认证机制 (影响: 全局) +所有 API 的 userId 由客户端 localStorage 传入,任何人可伪造身份操作他人数据。登录接口签发了用户信息但没有 JWT/Cookie,形同虚设。 + +### 2. AI/外部 API 错误被静默吞掉 (影响: 盲盒、位置、Lib) +ai.ts 所有函数的 catch 都是 return null,amap 调用无超时,blindboxPlanGen.ts 多处空 catch。生产环境问题排查极其困难。 + +### 3. 巨型组件/页面 (影响: 前端) +- blindbox/[code]/page.tsx: 1300行, 37个useState, 12个useEffect +- BlindboxPlan.tsx: 742行 +- blindbox/page.tsx: 658行, 18个useState +- MatchResult.tsx: 513行 + +### 4. 代码重复 (影响: 组件、位置、盲盒) +- ShareCard 三个文件 ~1024行,重复率 ~60% +- 高德 transit API 调用在 4处 复制粘贴 +- 标签更新映射代码 3处 重复 +- generateRoomCode 函数 2处 相同实现 + +### 5. Room 数据模型: JSON Blob 反模式 (影响: 房间、Lib) +Room.data 用一个 JSON 字符串存储全部状态,无法利用数据库约束、索引和关联查询。同项目的 BlindBoxRoom 系列已证明团队有能力做好关系化建模。 + +--- + +## 重构路线图 + +### P0 -- 立即修复 (安全/数据完整性) + +- [ ] **实现 JWT/httpOnly Cookie 认证链路** (2-3天) + - 登录签发 JWT,设置 httpOnly cookie + - 新增 getAuthUserId(req) 从 cookie 中提取用户 + - 所有 /api/user/*, /api/blindbox/*, /api/room/* 改用服务端鉴权 + - 前端移除 userId 参数传递,改为 cookie 自动携带 + +- [ ] **修复 atomicUpdateRoom 并发安全** (0.5天) + - SQLite 下使用 IMMEDIATE 事务或应用层锁 + - 如迁移到 PostgreSQL,使用 SELECT ... FOR UPDATE + +- [ ] **所有 catch 块至少加 console.error** (0.5天) + - ai.ts: tagIdea, suggestIdeas, generateSchedule, refinePlan, suggestAlternativeItems + - blindboxPlanGen.ts: 多处空 catch + - 前端组件: BlindboxPlan, MatchResult, SwipeDeck, RestaurantCard 等 + - API routes: applyTags 的 fire-and-forget + +### P1 -- 短期重构 (代码质量) + +- [ ] 拆分 blindbox/[code]/page.tsx 为 5-6 个子模块 + hooks (2-3天) +- [ ] 抽取 ShareCardShell 消除三兄弟重复 (1天) +- [ ] 拆分 BlindboxPlan.tsx (1天) +- [ ] 完善 amap.ts 为完整 API 客户端 (1天) +- [ ] 统一数据获取层 SWR 替代裸 fetch+useState (1-2天) + +### P2 -- 中期优化 (架构改善) + +- [ ] Room JSON blob 拆为关系化模型 (3-5天) +- [ ] 引入 zod 做 AI 返回值 + request body 校验 (1-2天) +- [ ] blindboxPlanGen.ts 拆为 4 个子模块 (1天) +- [ ] ApiError 独立 + validation 纯函数化 (0.5天) +- [ ] plan/route.ts PATCH/GET 内部拆分 (0.5天) + +### P3 -- 长期改善 + +- [ ] 部分页面引入 Server Component 混合渲染 +- [ ] 补全 a11y (aria-label、键盘导航) +- [ ] SSE 重连策略 (指数退避) +- [ ] 文件重命名 (store.ts → roomRepository.ts) + +--- + +## 各模块详细分析 + +### 模块1: 认证 & 用户 API (7.5/10) + +**亮点:** +- 已有 apiHandler 统一错误处理框架 +- validation.ts 提供输入校验工具 +- P2002 唯一约束冲突有分层防御 + +**问题:** +1. [Critical] userId 由客户端传入可伪造,无 JWT/session +2. [Critical] 登录成功后无状态维持(不签发 token) +3. [Warning] JSON.parse 部分未做防御处理 +4. [Warning] GET /api/user 无需鉴权即可查任意用户 email +5. [Warning] login 接口缺少暴力破解防护 +6. [Suggestion] 用户序列化逻辑重复 +7. [Suggestion] achievements 业务逻辑偏重可抽 service + +### 模块2: 房间投票 API (7.0/10) + +**亮点:** +- atomicUpdateRoom 原子操作封装 +- buildRoomStatus 视图层分离 +- roomEvents 发布/订阅模式 +- 各 handler 职责单一 (10-30行) + +**问题:** +1. [Critical] atomicUpdateRoom 事务隔离级别不足,并发 lost update +2. [Critical] Room.data JSON blob 反模式 +3. [Warning] SSE 连接中被踢用户无实时通知 +4. [Warning] 房间过期清理机制不可靠 (进程内变量) +5. [Warning] getRoomData 中 fire-and-forget delete +6. [Warning] buildRoomStatus 每次查 DB 获取 UserProfile,缺缓存 +7. [Suggestion] 房间 ID 使用 Math.random() 非密码学安全 + +### 模块3: 盲盒系统 API (7.0/10) + +**亮点:** +- Agent + Legacy 双路径容错设计 +- requireMembership 公共权限检查 +- IdeaTags 多维标签系统设计合理 +- 路由数量合理,职责清晰 + +**问题:** +1. [Critical] applyTags 静默吞掉所有错误 +2. [Critical] retag 端点串行处理无并发控制 +3. [Critical] plan/route.ts 227行承担过多职责 +4. [Warning] 标签更新映射代码 3处 重复 +5. [Warning] roomId 验证方式不一致 +6. [Warning] plan/stream 未使用 apiHandler +7. [Warning] plan GET 的 pending/history 有 N+1 查询 +8. [Suggestion] blindboxPlanGen.ts 808行应拆分 + +### 模块4: 位置服务 API (6.5/10) + +**亮点:** +- 三个 location 端点都有测试覆盖 +- apiHandler 统一包装 + +**问题:** +1. [Critical] 高德 transit API 调用在 4处 复制粘贴 +2. [Critical] fetch 调用缺少超时控制 +3. [Critical] debug 端点未用 apiHandler 且缺 try-catch +4. [Warning] amap.ts 封装过于薄弱 (仅 7行) +5. [Warning] 缺少缓存策略 +6. [Warning] fetch 响应未检查 HTTP 状态码 +7. [Warning] 输入验证不够严格 (经纬度未校验范围) +8. [Suggestion] POI 类型定义散落多文件 + +### 模块5: 前端页面 (5.5/10) + +**亮点:** +- 路由设计清晰语义明确 +- Skeleton 骨架屏使用 + +**问题:** +1. [Critical] blindbox/[code]/page.tsx 1300行 37个useState "上帝组件" +2. [Critical] blindbox/page.tsx 658行表单代码重复 +3. [Critical] profile/page.tsx 521行 18个useState +4. [Warning] 数据获取方式不一致 (裸fetch vs SWR) +5. [Warning] userId 通过 URL 参数传递 +6. [Warning] 错误静默吞掉 +7. [Warning] panic/page.tsx 598行位置搜索逻辑应抽取 +8. [Suggestion] 所有页面都是 "use client" 未利用 SSR +9. [Suggestion] 重复的页面布局模式 (ambient glow + 返回按钮) + +### 模块6: 组件 & Hooks (6.5/10) + +**亮点:** +- Hooks 设计整体合理 +- Toast 系统简洁 +- useShare 正确处理 AbortError + +**问题:** +1. [Critical] ShareCard 三兄弟 ~60% 代码重复 +2. [Critical] BlindboxPlan.tsx 742行职责过多 +3. [Warning] 多处错误被静默吞掉 +4. [Warning] useRoomPolling SSE 重连策略不足 +5. [Warning] MatchResult.tsx 513行职责偏重 +6. [Warning] useEffect 依赖项不完整 +7. [Suggestion] 可访问性(a11y)严重不足 +8. [Suggestion] ShareCard inline style 缺注释说明原因 + +### 模块7: 基础设施 & Lib (6.5/10) + +**亮点:** +- 前后端目前无实际交叉引用错误 +- sceneConfig/avatars 等纯函数设计良好 + +**问题:** +1. [Critical] Room JSON blob + WeekendPlan/Decision JSON 存储 +2. [Warning] store.ts 命名严重误导 (服务端代码叫 store) +3. [Warning] api.ts 混合两类职责 +4. [Warning] blindboxPlanGen.ts 808行过大 +5. [Warning] 两套房间模型 (Room vs BlindBoxRoom) 设计差异大 +6. [Warning] ai.ts AI 返回值校验不统一 +7. [Warning] userId.ts 基于 localStorage UUID 无安全性 +8. [Suggestion] runAgentLoop 硬编码 finalize_plan 工具名 +9. [Suggestion] roomEvents 进程内存级发布订阅 diff --git a/package-lock.json b/package-lock.json index 2ec05e0..370be2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "canvas-confetti": "^1.9.4", "framer-motion": "^12.34.3", "html-to-image": "^1.11.13", + "jose": "^6.1.3", "lucide-react": "^0.575.0", "next": "16.1.6", "openai": "^6.25.0", @@ -6767,6 +6768,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 53ffe7d..29e0ed5 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "canvas-confetti": "^1.9.4", "framer-motion": "^12.34.3", "html-to-image": "^1.11.13", + "jose": "^6.1.3", "lucide-react": "^0.575.0", "next": "16.1.6", "openai": "^6.25.0", diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index a603997..b44f989 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -65,7 +65,7 @@ export default function AchievementsPage() { setStats(data.stats); setDecisions(data.decisions); setContracts(data.contracts); - } catch { /* ignore */ } + } catch (e) { console.error("AchievementsPage: fetch failed:", e); } finally { setLoading(false); } })(); }, [router]); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index c802679..03e4d2b 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import bcrypt from "bcryptjs"; import { apiHandler, ApiError } from "@/lib/api"; +import { signToken, setAuthCookie } from "@/lib/auth"; export const POST = apiHandler(async (req) => { const { username, password } = await req.json(); @@ -14,9 +15,12 @@ export const POST = apiHandler(async (req) => { const valid = await bcrypt.compare(password, user.passwordHash); if (!valid) throw new ApiError("用户名或密码错误", 401); - return NextResponse.json({ + const token = await signToken(user.id); + const res = NextResponse.json({ id: user.id, username: user.username, avatar: user.avatar, }); + + return setAuthCookie(res, token); }); diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..0523538 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; +import { clearAuthCookie } from "@/lib/auth"; + +export async function POST() { + const res = NextResponse.json({ ok: true }); + return clearAuthCookie(res); +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index ea8c7bd..f932f75 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client"; import bcrypt from "bcryptjs"; import { apiHandler, ApiError } from "@/lib/api"; import { validateUsername, validatePassword } from "@/lib/validation"; +import { signToken, setAuthCookie } from "@/lib/auth"; export const POST = apiHandler(async (req) => { const { username, password, avatar } = await req.json(); @@ -24,11 +25,14 @@ export const POST = apiHandler(async (req) => { }, }); - return NextResponse.json({ + const token = await signToken(user.id); + const res = NextResponse.json({ id: user.id, username: user.username, avatar: user.avatar, }); + + return setAuthCookie(res, token); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") { throw new ApiError("用户名已被注册", 409); diff --git a/src/app/api/blindbox/draw/route.ts b/src/app/api/blindbox/draw/route.ts index 7adc1d7..1a8cb59 100644 --- a/src/app/api/blindbox/draw/route.ts +++ b/src/app/api/blindbox/draw/route.ts @@ -1,12 +1,13 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { requireMembership } from "@/lib/blindbox"; -import { apiHandler, ApiError, requireUserId } from "@/lib/api"; +import { apiHandler, ApiError } from "@/lib/api"; +import { getAuthUserId } from "@/lib/auth"; export const POST = apiHandler(async (req) => { - const { roomId, userId } = await req.json(); + const userId = await getAuthUserId(req); + const { roomId } = await req.json(); - requireUserId(userId); if (!roomId || typeof roomId !== "string") throw new ApiError("roomId 不能为空"); await requireMembership(roomId, userId); diff --git a/src/app/api/blindbox/plan/refine/route.ts b/src/app/api/blindbox/plan/refine/route.ts index bc0814e..d34553f 100644 --- a/src/app/api/blindbox/plan/refine/route.ts +++ b/src/app/api/blindbox/plan/refine/route.ts @@ -1,10 +1,11 @@ import { NextResponse } from "next/server"; -import { apiHandler, ApiError, requireUserId } from "@/lib/api"; +import { apiHandler, ApiError } from "@/lib/api"; import { refinePlan } from "@/lib/ai"; +import { getAuthUserId } from "@/lib/auth"; export const POST = apiHandler(async (req) => { - const { userId, instruction, days } = await req.json(); - requireUserId(userId); + await getAuthUserId(req); + const { instruction, days } = await req.json(); if (!instruction?.trim()) throw new ApiError("指令不能为空", 400); if (!Array.isArray(days) || days.length === 0) throw new ApiError("days 无效", 400); diff --git a/src/app/api/blindbox/plan/route.ts b/src/app/api/blindbox/plan/route.ts index 51c6b68..a2b61c5 100644 --- a/src/app/api/blindbox/plan/route.ts +++ b/src/app/api/blindbox/plan/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { requireMembership } from "@/lib/blindbox"; -import { apiHandler, ApiError, requireUserId } from "@/lib/api"; +import { apiHandler, ApiError } from "@/lib/api"; import { runPlanGeneration } from "@/lib/blindboxPlanGen"; +import { getAuthUserId } from "@/lib/auth"; interface AvailableTime { date: string; @@ -10,9 +11,9 @@ interface AvailableTime { } export const POST = apiHandler(async (req) => { - const { roomId, userId, availableTime } = await req.json(); + const userId = await getAuthUserId(req); + const { roomId, availableTime } = await req.json(); - requireUserId(userId); if (!roomId) throw new ApiError("roomId 不能为空"); await requireMembership(roomId, userId); @@ -27,7 +28,7 @@ export const POST = apiHandler(async (req) => { throw new ApiError("请选择有效的可用时间"); } - const result = await runPlanGeneration(roomId, userId!, at); + const result = await runPlanGeneration(roomId, userId, at); return NextResponse.json({ id: result.id, @@ -69,14 +70,15 @@ function computeEndTime(planData: string, now: Date): Date | null { } return base; - } catch { + } catch (e) { + console.error("computeEndTime failed:", e); return null; } } export const PATCH = apiHandler(async (req) => { - const { planId, userId, action, days } = await req.json(); - requireUserId(userId); + const userId = await getAuthUserId(req); + const { planId, action, days } = await req.json(); if (!planId) throw new ApiError("planId 不能为空"); const { prisma } = await import("@/lib/prisma"); @@ -129,10 +131,9 @@ export const PATCH = apiHandler(async (req) => { }); export const GET = apiHandler(async (req) => { + const userId = await getAuthUserId(req); const { searchParams } = new URL(req.url); const mode = searchParams.get("mode") || "latest"; - const userId = searchParams.get("userId"); - requireUserId(userId); const { prisma } = await import("@/lib/prisma"); @@ -141,7 +142,7 @@ export const GET = apiHandler(async (req) => { if (!roomId) throw new ApiError("roomId 不能为空"); const plan = await prisma.weekendPlan.findFirst({ - where: { roomId, userId: userId!, status: "accepted" }, + where: { roomId, userId, status: "accepted" }, orderBy: { createdAt: "desc" }, select: { id: true, planData: true, endTime: true, createdAt: true }, }); @@ -156,7 +157,7 @@ export const GET = apiHandler(async (req) => { if (mode === "pending") { const plans = await prisma.weekendPlan.findMany({ where: { - userId: userId!, + userId, status: "accepted", endTime: { not: null, lt: new Date() }, }, @@ -190,7 +191,7 @@ export const GET = apiHandler(async (req) => { if (mode === "history") { const plans = await prisma.weekendPlan.findMany({ where: { - userId: userId!, + userId, status: { in: ["completed", "expired"] }, }, orderBy: { createdAt: "desc" }, diff --git a/src/app/api/blindbox/plan/stream/route.ts b/src/app/api/blindbox/plan/stream/route.ts index 93d2a92..b5921fc 100644 --- a/src/app/api/blindbox/plan/stream/route.ts +++ b/src/app/api/blindbox/plan/stream/route.ts @@ -1,23 +1,24 @@ +import { NextRequest } from "next/server"; import { requireMembership } from "@/lib/blindbox"; -import { requireUserId } from "@/lib/api"; import { runPlanGeneration } from "@/lib/blindboxPlanGen"; +import { getAuthUserId } from "@/lib/auth"; function encodeSSE(event: string, data: string): string { return `event: ${event}\ndata: ${data}\n\n`; } -export async function POST(req: Request): Promise { +export async function POST(req: NextRequest): Promise { let roomId: string; let userId: string; let availableTime: { date: string; startHour: number; endHour: number }; try { + userId = await getAuthUserId(req); + const body = await req.json(); roomId = body.roomId; - userId = body.userId; availableTime = body.availableTime; - requireUserId(userId); if (!roomId) { return new Response( JSON.stringify({ error: "roomId 不能为空" }), @@ -41,8 +42,9 @@ export async function POST(req: Request): Promise { } } catch (e) { const message = e instanceof Error ? e.message : "请求参数错误"; + const status = e instanceof Error && "status" in e ? (e as { status: number }).status : 400; return new Response(JSON.stringify({ error: message }), { - status: 400, + status, headers: { "Content-Type": "application/json" }, }); } @@ -55,7 +57,7 @@ export async function POST(req: Request): Promise { }; try { - const result = await runPlanGeneration(roomId!, userId!, availableTime!, (message) => { + const result = await runPlanGeneration(roomId, userId, availableTime, (message) => { push("status", message); }); push("plan", JSON.stringify({ id: result.id, days: result.days, createdAt: result.createdAt })); diff --git a/src/app/api/blindbox/plan/suggest-item/route.ts b/src/app/api/blindbox/plan/suggest-item/route.ts index 0fe2e84..c5086e3 100644 --- a/src/app/api/blindbox/plan/suggest-item/route.ts +++ b/src/app/api/blindbox/plan/suggest-item/route.ts @@ -33,7 +33,7 @@ export const POST = apiHandler(async (req) => { reason: alt.reason, }; } - } catch { /* ignore, use fallback */ } + } catch (e) { console.error("suggest-item: POI search failed, using fallback:", e); } return { activity: alt.activity, poi: alt.searchQuery, diff --git a/src/app/api/blindbox/retag/route.ts b/src/app/api/blindbox/retag/route.ts index a528829..b3e9ccd 100644 --- a/src/app/api/blindbox/retag/route.ts +++ b/src/app/api/blindbox/retag/route.ts @@ -1,13 +1,14 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { requireMembership } from "@/lib/blindbox"; -import { apiHandler, requireUserId } from "@/lib/api"; +import { apiHandler } from "@/lib/api"; +import { getAuthUserId } from "@/lib/auth"; import { tagIdea } from "@/lib/ai"; export const POST = apiHandler(async (req) => { - const { roomId, userId } = await req.json(); + const userId = await getAuthUserId(req); + const { roomId } = await req.json(); - requireUserId(userId); await requireMembership(roomId, userId); // Find all untagged ideas in this room (any member's ideas) diff --git a/src/app/api/blindbox/room/[code]/route.ts b/src/app/api/blindbox/room/[code]/route.ts index 9fb8ffe..a8ff44a 100644 --- a/src/app/api/blindbox/room/[code]/route.ts +++ b/src/app/api/blindbox/room/[code]/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { getRoomByCode, requireMembership } from "@/lib/blindbox"; -import { apiHandler, ApiError, requireUserId } from "@/lib/api"; +import { apiHandler, ApiError } from "@/lib/api"; +import { getAuthUserId } from "@/lib/auth"; export const GET = apiHandler(async (_req, { params }) => { const { code } = await params; @@ -27,10 +28,9 @@ export const GET = apiHandler(async (_req, { params }) => { }); export const PATCH = apiHandler(async (req, { params }) => { + const userId = await getAuthUserId(req); const { code } = await params; - const { userId, city, address, lat, lng } = await req.json(); - - requireUserId(userId); + const { city, address, lat, lng } = await req.json(); const room = await prisma.blindBoxRoom.findUnique({ where: { code: code.toUpperCase() }, @@ -67,10 +67,8 @@ export const PATCH = apiHandler(async (req, { params }) => { }); export const DELETE = apiHandler(async (req, { params }) => { + const userId = await getAuthUserId(req); const { code } = await params; - const { userId } = await req.json(); - - requireUserId(userId); const room = await prisma.blindBoxRoom.findUnique({ where: { code: code.toUpperCase() }, diff --git a/src/app/api/blindbox/room/join/route.ts b/src/app/api/blindbox/room/join/route.ts index 98b3208..7094cde 100644 --- a/src/app/api/blindbox/room/join/route.ts +++ b/src/app/api/blindbox/room/join/route.ts @@ -1,11 +1,12 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { apiHandler, ApiError, requireUserId } from "@/lib/api"; +import { apiHandler, ApiError } from "@/lib/api"; +import { getAuthUserId } from "@/lib/auth"; export const POST = apiHandler(async (req) => { - const { userId, code } = await req.json(); + const userId = await getAuthUserId(req); + const { code } = await req.json(); - requireUserId(userId); if (!code || typeof code !== "string") throw new ApiError("请输入房间号"); const room = await prisma.blindBoxRoom.findUnique({ diff --git a/src/app/api/blindbox/room/route.ts b/src/app/api/blindbox/room/route.ts index f7db278..fba3f49 100644 --- a/src/app/api/blindbox/room/route.ts +++ b/src/app/api/blindbox/room/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { generateUniqueRoomCode } from "@/lib/blindbox"; -import { apiHandler, requireUserId, requireUser } from "@/lib/api"; +import { apiHandler, requireUser } from "@/lib/api"; import { validateRoomName } from "@/lib/validation"; +import { getAuthUserId } from "@/lib/auth"; export const POST = apiHandler(async (req) => { - const { userId, name } = await req.json(); - - requireUserId(userId); + const userId = await getAuthUserId(req); + const { name } = await req.json(); const roomName = validateRoomName(name); diff --git a/src/app/api/blindbox/rooms/route.ts b/src/app/api/blindbox/rooms/route.ts index 824a10a..db278be 100644 --- a/src/app/api/blindbox/rooms/route.ts +++ b/src/app/api/blindbox/rooms/route.ts @@ -1,9 +1,10 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { apiHandler, requireUserId } from "@/lib/api"; +import { apiHandler } from "@/lib/api"; +import { getAuthUserId } from "@/lib/auth"; export const GET = apiHandler(async (req) => { - const userId = requireUserId(req.nextUrl.searchParams.get("userId")); + const userId = await getAuthUserId(req); const memberships = await prisma.blindBoxMember.findMany({ where: { userId }, diff --git a/src/app/api/blindbox/route.ts b/src/app/api/blindbox/route.ts index 127ede3..a59aaec 100644 --- a/src/app/api/blindbox/route.ts +++ b/src/app/api/blindbox/route.ts @@ -1,9 +1,10 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { requireMembership } from "@/lib/blindbox"; -import { apiHandler, ApiError, requireUserId } from "@/lib/api"; +import { apiHandler, ApiError } from "@/lib/api"; import { validateIdeaContent, requireString } from "@/lib/validation"; import { tagIdea } from "@/lib/ai"; +import { getAuthUserId } from "@/lib/auth"; const TAG_TIMEOUT_MS = 60_000; @@ -26,13 +27,13 @@ function applyTags(ideaId: string, content: string) { }, }); }) - .catch(() => {}); + .catch((e) => { console.error("updateIdeaTags: background tag update failed:", e); }); } export const POST = apiHandler(async (req) => { - const { roomId, userId, content } = await req.json(); + const userId = await getAuthUserId(req); + const { roomId, content } = await req.json(); - requireUserId(userId); requireString(roomId, "roomId"); const trimmedContent = validateIdeaContent(content); @@ -48,7 +49,7 @@ export const POST = apiHandler(async (req) => { }); export const GET = apiHandler(async (req) => { - const userId = requireUserId(req.nextUrl.searchParams.get("userId")); + const userId = await getAuthUserId(req); const roomId = requireString(req.nextUrl.searchParams.get("roomId"), "roomId"); await requireMembership(roomId, userId); @@ -88,9 +89,9 @@ export const GET = apiHandler(async (req) => { }); export const PUT = apiHandler(async (req) => { - const { ideaId, userId, content } = await req.json(); + const userId = await getAuthUserId(req); + const { ideaId, content } = await req.json(); - requireUserId(userId); requireString(ideaId, "ideaId"); const trimmedContent = validateIdeaContent(content); @@ -107,9 +108,9 @@ export const PUT = apiHandler(async (req) => { }); export const DELETE = apiHandler(async (req) => { - const { ideaId, userId } = await req.json(); + const userId = await getAuthUserId(req); + const { ideaId } = await req.json(); - requireUserId(userId); requireString(ideaId, "ideaId"); const { count } = await prisma.blindBoxIdea.deleteMany({ diff --git a/src/app/api/blindbox/suggest/route.ts b/src/app/api/blindbox/suggest/route.ts index 9b7c96c..f5fe5a2 100644 --- a/src/app/api/blindbox/suggest/route.ts +++ b/src/app/api/blindbox/suggest/route.ts @@ -1,18 +1,18 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { requireMembership } from "@/lib/blindbox"; -import { apiHandler, ApiError, requireUserId } from "@/lib/api"; +import { apiHandler, ApiError } from "@/lib/api"; +import { getAuthUserId } from "@/lib/auth"; import { suggestIdeas } from "@/lib/ai"; export const GET = apiHandler(async (req) => { + const userId = await getAuthUserId(req); const { searchParams } = new URL(req.url); const roomId = searchParams.get("roomId"); - const userId = searchParams.get("userId"); - requireUserId(userId); if (!roomId) throw new ApiError("roomId 不能为空"); - await requireMembership(roomId, userId!); + await requireMembership(roomId, userId); const recentIdeas = await prisma.blindBoxIdea.findMany({ where: { roomId, status: "in_pool" }, diff --git a/src/app/api/room/[id]/events/route.ts b/src/app/api/room/[id]/events/route.ts index a5fe318..037413e 100644 --- a/src/app/api/room/[id]/events/route.ts +++ b/src/app/api/room/[id]/events/route.ts @@ -59,8 +59,8 @@ export async function GET( try { const status = await buildRoomStatus(id); if (status && alive) send(status); - } catch { - /* ignore transient read errors */ + } catch (e) { + console.error("SSE: transient read error:", e); } }); diff --git a/src/app/api/user/achievements/route.ts b/src/app/api/user/achievements/route.ts index 4b4a5e3..55d2e5f 100644 --- a/src/app/api/user/achievements/route.ts +++ b/src/app/api/user/achievements/route.ts @@ -1,20 +1,20 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { apiHandler, requireUserId } from "@/lib/api"; +import { apiHandler } from "@/lib/api"; +import { getAuthUserId } from "@/lib/auth"; export const GET = apiHandler(async (req) => { - const userId = req.nextUrl.searchParams.get("userId"); - requireUserId(userId); + const userId = await getAuthUserId(req); const [decisions, contracts] = await Promise.all([ prisma.decision.findMany({ - where: { userId: userId! }, + where: { userId }, orderBy: { createdAt: "desc" }, take: 50, }), prisma.weekendPlan.findMany({ where: { - userId: userId!, + userId, status: { in: ["completed", "expired"] }, }, orderBy: { createdAt: "desc" }, diff --git a/src/app/api/user/favorite/route.ts b/src/app/api/user/favorite/route.ts index 0095884..31c3ebd 100644 --- a/src/app/api/user/favorite/route.ts +++ b/src/app/api/user/favorite/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { Prisma } from "@prisma/client"; -import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api"; +import { apiHandler, ApiError, requireUser } from "@/lib/api"; +import { getAuthUserId } from "@/lib/auth"; export const GET = apiHandler(async (req) => { - const userId = req.nextUrl.searchParams.get("userId"); - if (!userId) return NextResponse.json([]); + const userId = await getAuthUserId(req); const favorites = await prisma.favorite.findMany({ where: { userId }, @@ -23,9 +23,9 @@ export const GET = apiHandler(async (req) => { }); export const POST = apiHandler(async (req) => { - const { userId, restaurant } = await req.json(); + const userId = await getAuthUserId(req); + const { restaurant } = await req.json(); - requireUserId(userId); if (!restaurant?.id || typeof restaurant.id !== "string") { throw new ApiError("缺少必要字段"); } @@ -53,9 +53,9 @@ export const POST = apiHandler(async (req) => { }); export const DELETE = apiHandler(async (req) => { - const { userId, favoriteId } = await req.json(); + const userId = await getAuthUserId(req); + const { favoriteId } = await req.json(); - requireUserId(userId); if (!favoriteId) throw new ApiError("缺少必要字段"); const fav = await prisma.favorite.findUnique({ where: { id: favoriteId } }); diff --git a/src/app/api/user/history/route.ts b/src/app/api/user/history/route.ts index b68e669..aba9d35 100644 --- a/src/app/api/user/history/route.ts +++ b/src/app/api/user/history/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api"; +import { apiHandler, ApiError, requireUser } from "@/lib/api"; +import { getAuthUserId } from "@/lib/auth"; const MAX_HISTORY = 50; export const GET = apiHandler(async (req) => { - const userId = req.nextUrl.searchParams.get("userId"); - if (!userId) return NextResponse.json([]); + const userId = await getAuthUserId(req); const decisions = await prisma.decision.findMany({ where: { userId }, @@ -28,10 +28,9 @@ export const GET = apiHandler(async (req) => { }); export const POST = apiHandler(async (req) => { - const { userId, roomId, restaurant, matchType, participants } = - await req.json(); + const userId = await getAuthUserId(req); + const { roomId, restaurant, matchType, participants } = await req.json(); - requireUserId(userId); if (!roomId || !restaurant || !matchType) { throw new ApiError("缺少必要字段"); } diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index 9db9ed2..ce6ed2b 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -2,17 +2,29 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { Prisma } from "@prisma/client"; import bcrypt from "bcryptjs"; -import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api"; +import { apiHandler, ApiError, requireUser } from "@/lib/api"; import { validateUsername, validatePassword, validateEmail } from "@/lib/validation"; +import { getAuthUserId } from "@/lib/auth"; export const GET = apiHandler(async (req) => { - const userId = req.nextUrl.searchParams.get("id"); - if (!userId) return NextResponse.json(null); + // GET still allows querying by id param (for public profile viewing) + // but sensitive fields are only shown to the owner + const queryId = req.nextUrl.searchParams.get("id"); + if (!queryId) return NextResponse.json(null); - const user = await prisma.user.findUnique({ where: { id: userId } }); + const user = await prisma.user.findUnique({ where: { id: queryId } }); if (!user) return NextResponse.json(null); - const decisionCount = await prisma.decision.count({ where: { userId } }); + const decisionCount = await prisma.decision.count({ where: { userId: queryId } }); + + // Check if the requester is the profile owner + let isOwner = false; + try { + const authId = await getAuthUserId(req); + isOwner = authId === queryId; + } catch { + // Not logged in — show public profile only + } let preferences = {}; try { preferences = JSON.parse(user.preferences); } catch { /* fallback */ } @@ -21,18 +33,17 @@ export const GET = apiHandler(async (req) => { id: user.id, username: user.username, avatar: user.avatar, - email: user.email, - preferences, + email: isOwner ? user.email : undefined, + preferences: isOwner ? preferences : undefined, createdAt: user.createdAt.toISOString(), decisionCount, }); }); export const PUT = apiHandler(async (req) => { + const userId = await getAuthUserId(req); const body = await req.json(); - const { userId } = body; - requireUserId(userId); const existing = await requireUser(userId); const updateData: Record = {}; diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx index 18921b6..ab510f1 100644 --- a/src/app/blindbox/[code]/page.tsx +++ b/src/app/blindbox/[code]/page.tsx @@ -184,7 +184,7 @@ export default function BlindboxRoomPage() { setMyIdeas(data.myIdeas ?? []); setDrawnHistory(data.drawn ?? []); } - } catch { /* ignore */ } + } catch (e) { console.error("fetchIdeas failed:", e); } }, [room]); const fetchSuggestions = useCallback(async () => { @@ -202,7 +202,7 @@ export default function BlindboxRoomPage() { return; } } - } catch { /* ignore */ } + } catch (e) { console.error("fetchSuggestions failed:", e); } setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4)); setSuggestionsSource("static"); setSuggestionsLoading(false); @@ -230,7 +230,7 @@ export default function BlindboxRoomPage() { endTime: data.plan.endTime ?? null, }); } - } catch { /* ignore */ } + } catch (e) { console.error("fetchAcceptedPlan failed:", e); } }, [room]); useEffect(() => { @@ -251,7 +251,7 @@ export default function BlindboxRoomPage() { if (!res.ok) return; const data = await res.json(); if (data.pending?.length) setPendingContracts(data.pending); - } catch { /* ignore */ } + } catch (e) { console.error("fetchPendingContracts failed:", e); } })(); }, [isMember]); @@ -281,7 +281,7 @@ export default function BlindboxRoomPage() { fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`) .then((r) => r.json()) .then((d) => { if (d.pending?.length) setPendingContracts(d.pending); }) - .catch(() => {}); + .catch((e) => { console.error("refreshPendingContracts failed:", e); }); } }, ms); @@ -314,7 +314,7 @@ export default function BlindboxRoomPage() { setIsMember(true); fetchRoom(); } - } catch { /* ignore */ } + } catch (e) { console.error("handleJoinRoom failed:", e); } finally { setJoiningRoom(false); } }; @@ -1151,7 +1151,7 @@ export default function BlindboxRoomPage() { days: planDays, endTime: data.endTime ?? null, }); - } catch { /* best-effort */ } + } catch (e) { console.error("acceptPlan failed:", e); } } toast.show("契约已接受!"); timersRef.current.push(setTimeout(() => { diff --git a/src/app/panic/page.tsx b/src/app/panic/page.tsx index 7cbd9cf..89c4d4b 100644 --- a/src/app/panic/page.tsx +++ b/src/app/panic/page.tsx @@ -80,7 +80,8 @@ export default function PanicPage() { const data: LocationSuggestion[] = await res.json(); setSuggestions(Array.isArray(data) ? data : []); setShowSuggestions(Array.isArray(data) && data.length > 0); - } catch { + } catch (e) { + console.error("PanicPage: fetchSuggestions failed:", e); setSuggestions([]); } finally { setFetchingSuggestions(false); @@ -178,7 +179,8 @@ export default function PanicPage() { try { await joinRoom(roomCode, getUserId()); router.push(`/room/${roomCode}`); - } catch { + } catch (e) { + console.error("PanicPage: handleJoin failed:", e); setError("房间不存在,请检查房间号"); setLoading(false); } diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 317ab09..af1055e 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -80,7 +80,8 @@ export default function ProfilePage() { router.push("/"); } }) - .catch(() => { + .catch((e) => { + console.error("ProfilePage: fetch user failed:", e); setProfile({ ...cached }); }) .finally(() => setLoading(false)); @@ -93,7 +94,7 @@ export default function ProfilePage() { fetch(`/api/user/favorite?userId=${userId}`) .then((r) => { if (!r.ok) throw new Error(); return r.json(); }) .then((data) => setFavorites(Array.isArray(data) ? data : [])) - .catch(() => {}) + .catch((e) => { console.error("ProfilePage: fetch favorites failed:", e); }) .finally(() => setFavLoading(false)); }, [userId]); @@ -226,8 +227,8 @@ export default function ProfilePage() { } }; - const handleLogout = () => { - logout(); + const handleLogout = async () => { + await logout(); router.push("/"); }; diff --git a/src/components/BlindboxPlan.tsx b/src/components/BlindboxPlan.tsx index 08bb418..1ca0ce7 100644 --- a/src/components/BlindboxPlan.tsx +++ b/src/components/BlindboxPlan.tsx @@ -128,7 +128,7 @@ function PoiSearchField({ poi, address, onSelect, location }: PoiSearchFieldProp setSuggestions(data); setOpen(data.length > 0); } - } catch { /* ignore */ } + } catch (e) { console.error("PoiSearchField fetch failed:", e); } finally { setLoading(false); } }, 400); return () => { if (timerRef.current) clearTimeout(timerRef.current); }; diff --git a/src/components/ContractCompletionModal.tsx b/src/components/ContractCompletionModal.tsx index 82740c9..4453ab2 100644 --- a/src/components/ContractCompletionModal.tsx +++ b/src/components/ContractCompletionModal.tsx @@ -38,7 +38,7 @@ export default function ContractCompletionModal({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ planId: contract.id, userId, action }), }); - } catch { /* best-effort */ } + } catch (e) { console.error("ContractCompletionModal: submit failed:", e); } setLoading(false); if (current < contracts.length - 1) { diff --git a/src/components/MatchResult.tsx b/src/components/MatchResult.tsx index bbe99d2..2ee63de 100644 --- a/src/components/MatchResult.tsx +++ b/src/components/MatchResult.tsx @@ -107,7 +107,7 @@ export default function MatchResult({ matchType, participants: userCount, }), - }).catch(() => {}); + }).catch((e) => { console.error("MatchResult: save history failed:", e); }); }, [registered, userId, roomId, restaurant, matchType, userCount]); const handleOpenShareCard = useCallback(() => { @@ -136,8 +136,8 @@ export default function MatchResult({ setFavorited(true); toast.show("已收藏"); } - } catch { - /* ignore */ + } catch (e) { + console.error("MatchResult: handleFavorite failed:", e); } setFavLoading(false); }, [registered, userId, restaurant, favorited, favLoading, toast]); diff --git a/src/components/RestaurantCard.tsx b/src/components/RestaurantCard.tsx index e0ebeac..a104bcc 100644 --- a/src/components/RestaurantCard.tsx +++ b/src/components/RestaurantCard.tsx @@ -146,7 +146,7 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant body: JSON.stringify({ userId: getUserId(), restaurant }), }); if (res.ok) setFavorited(true); - } catch {} + } catch (e) { console.error("RestaurantCard: handleFavorite failed:", e); } }, [restaurant, favorited]); const openLink = useCallback( (url: string) => (e: React.MouseEvent | React.TouchEvent) => { diff --git a/src/components/SwipeDeck.tsx b/src/components/SwipeDeck.tsx index 819d466..4e8a2f2 100644 --- a/src/components/SwipeDeck.tsx +++ b/src/components/SwipeDeck.tsx @@ -227,8 +227,8 @@ export default function SwipeDeck({ if (data.match != null) { setLocalMatchId(data.match); } - } catch { - // Polling will catch match state + } catch (e) { + console.error("SwipeDeck: sendSwipe failed:", e); } }; @@ -273,8 +273,8 @@ export default function SwipeDeck({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, restaurantId: lastRid }), }); - } catch { - // Best-effort + } catch (e) { + console.error("SwipeDeck: handleUndo failed:", e); } setSwipeHistory((h) => h.slice(0, -1)); diff --git a/src/hooks/useGeolocation.ts b/src/hooks/useGeolocation.ts index 2f154ca..69c09af 100644 --- a/src/hooks/useGeolocation.ts +++ b/src/hooks/useGeolocation.ts @@ -37,7 +37,8 @@ async function reverseGeocode(lat: number, lng: number): Promise const res = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`); const data = await res.json(); return data.name || data.formatted || null; - } catch { + } catch (e) { + console.error("reverseGeocode failed:", e); return null; } } diff --git a/src/hooks/useRoomPolling.ts b/src/hooks/useRoomPolling.ts index b2a76e7..7f119cc 100644 --- a/src/hooks/useRoomPolling.ts +++ b/src/hooks/useRoomPolling.ts @@ -36,8 +36,8 @@ export function useRoomPolling(roomId: string | undefined) { if (parsed.roomId) { mutate(parsed, { revalidate: false }); } - } catch { - /* malformed message */ + } catch (e) { + console.error("useRoomPolling: malformed SSE message:", e); } }; diff --git a/src/lib/ai.ts b/src/lib/ai.ts index e42401b..ea14368 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -132,7 +132,8 @@ export async function tagIdea(content: string): Promise { searchQuery: parsed.searchQuery, searchType: parsed.searchType, }; - } catch { + } catch (e) { + console.error("tagIdea failed:", e); return null; } } @@ -170,7 +171,8 @@ export async function suggestIdeas(existingIdeas: string[]): Promise { return parsed.suggestions .filter((s: unknown) => typeof s === "string" && s.length > 0) .slice(0, 4); - } catch { + } catch (e) { + console.error("suggestIdeas failed:", e); return []; } } @@ -236,7 +238,8 @@ ${Object.entries(ctx.candidates) })), summary: String(parsed.summary ?? ""), }; - } catch { + } catch (e) { + console.error("generateSchedule failed:", e); return null; } } @@ -284,7 +287,8 @@ export async function refinePlan( })) return null; return result as import("@/types").WeekendPlanData[]; - } catch { + } catch (e) { + console.error("refinePlan failed:", e); return null; } } @@ -337,7 +341,8 @@ export async function suggestAlternativeItems( typeof (a as Record).searchQuery === "string", ) .slice(0, 3) as Array<{ activity: string; searchQuery: string; reason: string }>; - } catch { + } catch (e) { + console.error("suggestAlternativeItems failed:", e); return null; } } @@ -437,7 +442,8 @@ export async function runAgentLoop( let args: Record; try { args = JSON.parse(toolCall.function.arguments); - } catch { + } catch (e) { + console.error("runAgentLoop: failed to parse tool arguments:", e); args = {}; } diff --git a/src/lib/api.ts b/src/lib/api.ts index 3a91cda..af8b3a9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -12,7 +12,11 @@ export class ApiError extends Error { } } -/** Validates that value is a non-empty string; throws 401 otherwise. */ +/** + * Validates that value is a non-empty string; throws 401 otherwise. + * Used for room routes where anonymous users pass userId in body. + * For registered-user routes, prefer getAuthUserId() from lib/auth. + */ export function requireUserId(value: unknown): string { if (!value || typeof value !== "string") { throw new ApiError("请先登录", 401); diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..642ae85 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,80 @@ +import { SignJWT, jwtVerify } from "jose"; +import { NextRequest, NextResponse } from "next/server"; +import { ApiError } from "@/lib/api"; + +const COOKIE_NAME = "nw_token"; +const TOKEN_MAX_AGE = 7 * 24 * 60 * 60; // 7 days in seconds + +function getSecret() { + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error("JWT_SECRET environment variable is not set"); + } + return new TextEncoder().encode(secret); +} + +export async function signToken(userId: string): Promise { + return new SignJWT({ sub: userId }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(`${TOKEN_MAX_AGE}s`) + .sign(getSecret()); +} + +export async function verifyToken(token: string): Promise { + const { payload } = await jwtVerify(token, getSecret()); + if (!payload.sub) throw new Error("Invalid token payload"); + return payload.sub; +} + +export function setAuthCookie(res: NextResponse, token: string): NextResponse { + res.cookies.set(COOKIE_NAME, token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: TOKEN_MAX_AGE, + }); + return res; +} + +export function clearAuthCookie(res: NextResponse): NextResponse { + res.cookies.set(COOKIE_NAME, "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 0, + }); + return res; +} + +/** + * Extracts authenticated userId from JWT cookie. + * Throws 401 if no valid token is present. + */ +export async function getAuthUserId(req: NextRequest): Promise { + const token = req.cookies.get(COOKIE_NAME)?.value; + if (!token) { + throw new ApiError("请先登录", 401); + } + try { + return await verifyToken(token); + } catch { + throw new ApiError("登录已过期,请重新登录", 401); + } +} + +/** + * Optionally extracts userId from JWT cookie. + * Returns null if no valid token (does not throw). + */ +export async function getOptionalAuthUserId(req: NextRequest): Promise { + const token = req.cookies.get(COOKIE_NAME)?.value; + if (!token) return null; + try { + return await verifyToken(token); + } catch { + return null; + } +} diff --git a/src/lib/blindboxPlanGen.ts b/src/lib/blindboxPlanGen.ts index 9399328..5df4cbc 100644 --- a/src/lib/blindboxPlanGen.ts +++ b/src/lib/blindboxPlanGen.ts @@ -319,7 +319,8 @@ function buildAgentTools( try { const pois = await searchPois(query, searchType, lat, lng); return JSON.stringify(pois); - } catch { + } catch (e) { + console.error("searchPoiTool failed:", e); return JSON.stringify([]); } }, @@ -369,7 +370,8 @@ function buildAgentTools( const distanceKm = Math.round(Number(data.route.distance) / 100) / 10; const { description, mode } = parseTransitSegments(transit.segments ?? []); return JSON.stringify({ durationMin, distanceKm, description, mode }); - } catch { + } catch (e) { + console.error("getTravelTimeTool failed:", e); return JSON.stringify({ error: "路线查询失败" }); } }, @@ -535,7 +537,8 @@ async function runLegacyPlanGeneration( try { const pois = await searchPois(idea.searchQuery, idea.searchType, room.lat, room.lng); return { query: idea.searchQuery, pois }; - } catch { + } catch (e) { + console.error(`searchPois failed for "${idea.searchQuery}":`, e); return { query: idea.searchQuery, pois: [] }; } }), @@ -560,7 +563,8 @@ async function runLegacyPlanGeneration( try { const pois = await searchPois(idea.searchQuery, idea.searchType, anchorLat, anchorLng); return { query: idea.searchQuery, pois }; - } catch { + } catch (e) { + console.error(`searchPois (category) failed for "${idea.searchQuery}":`, e); return { query: idea.searchQuery, pois: [] }; } }), @@ -643,7 +647,8 @@ async function queryTransit( const transit = data.route.transits[0]; const { description } = parseTransitSegments(transit.segments ?? []); return { durationMin: Math.ceil(Number(transit.duration) / 60), description }; - } catch { + } catch (e) { + console.error("queryTransit failed:", e); return null; } } @@ -770,7 +775,8 @@ export async function runPlanGeneration( onProgress, ); days = agentResult.days; - } catch { + } catch (e) { + console.error("runAgentPlanGeneration failed, falling back to legacy:", e); onProgress?.("使用备用方案规划..."); const legacyResult = await runLegacyPlanGeneration( { lat: room.lat, lng: room.lng }, diff --git a/src/lib/shareImage.ts b/src/lib/shareImage.ts index 9bb75a9..09423b0 100644 --- a/src/lib/shareImage.ts +++ b/src/lib/shareImage.ts @@ -15,7 +15,8 @@ export async function loadImageAsDataUrl(src: string): Promise { const ctx = canvas.getContext("2d")!; ctx.drawImage(img, 0, 0); return canvas.toDataURL("image/jpeg", 0.85); - } catch { + } catch (e) { + console.error("proxyToDataUrl failed:", e); return null; } } diff --git a/src/lib/store.ts b/src/lib/store.ts index 8e2d775..dfda071 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -102,32 +102,57 @@ export async function getRoomData( const room = await prisma.room.findUnique({ where: { id: roomId } }); if (!room) return null; if (room.expiresAt < new Date()) { - prisma.room.delete({ where: { id: roomId } }).catch(() => {}); + await prisma.room.delete({ where: { id: roomId } }).catch((e) => { + console.error(`Failed to delete expired room ${roomId}:`, e); + }); return null; } return normalize(JSON.parse(room.data)); } /** - * Atomic read-modify-write within a Prisma transaction. - * Prevents race conditions when multiple users swipe concurrently. + * Per-room mutex to serialize concurrent read-modify-write operations. + * SQLite doesn't support row-level locks (SELECT ... FOR UPDATE), + * so we use an application-level lock to prevent lost updates. + */ +const roomLocks = new Map>(); + +function withRoomLock(roomId: string, fn: () => Promise): Promise { + const prev = roomLocks.get(roomId) ?? Promise.resolve(); + const next = prev.then(fn, fn); + roomLocks.set(roomId, next); + // Cleanup the lock entry when the chain settles + next.finally(() => { + if (roomLocks.get(roomId) === next) { + roomLocks.delete(roomId); + } + }); + return next; +} + +/** + * Atomic read-modify-write with per-room serialization. + * Uses an application-level mutex to prevent concurrent lost updates, + * since SQLite lacks row-level locking. */ export async function atomicUpdateRoom( roomId: string, updater: (data: RoomData) => RoomData, ): Promise { - return prisma.$transaction(async (tx) => { - const room = await tx.room.findUnique({ where: { id: roomId } }); - if (!room) return null; + return withRoomLock(roomId, () => + prisma.$transaction(async (tx) => { + const room = await tx.room.findUnique({ where: { id: roomId } }); + if (!room) return null; - const data = normalize(JSON.parse(room.data)); - const updated = updater(data); + const data = normalize(JSON.parse(room.data)); + const updated = updater(data); - await tx.room.update({ - where: { id: roomId }, - data: { data: JSON.stringify(updated) }, - }); + await tx.room.update({ + where: { id: roomId }, + data: { data: JSON.stringify(updated) }, + }); - return updated; - }); + return updated; + }), + ); } diff --git a/src/lib/userId.ts b/src/lib/userId.ts index 90a77dd..8c600e7 100644 --- a/src/lib/userId.ts +++ b/src/lib/userId.ts @@ -55,8 +55,14 @@ export function setCachedPreferences(prefs: UserPreferences): void { localStorage.setItem("nowhatever_preferences", JSON.stringify(prefs)); } -export function logout(): void { +export async function logout(): Promise { if (typeof window === "undefined") return; + // Clear server-side auth cookie + try { + await fetch("/api/auth/logout", { method: "POST" }); + } catch { + // Best-effort: cookie will expire anyway + } localStorage.removeItem(PROFILE_KEY); localStorage.removeItem("nowhatever_preferences"); const newId = crypto.randomUUID();