refactor(P0): JWT 认证、并发安全、错误日志三项安全加固
- 新增 JWT httpOnly cookie 认证链路 (jose),登录/注册签发 token, 所有用户和盲盒 API 改为从 cookie 提取 userId,不再信任客户端传值 - 新增 /api/auth/logout 端点清除认证 cookie - GET /api/user 区分 owner/非 owner,非 owner 不暴露 email - atomicUpdateRoom 新增 per-room 应用层互斥锁,防止 SQLite 下并发 lost update - 修复 getRoomData 中 fire-and-forget delete 改为 await - 37 个静默 catch 块跨 17 个文件添加 console.error 日志 - 新增 REFACTOR_PLAN.md 全景分析文档
This commit is contained in:
@@ -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 进程内存级发布订阅
|
||||||
Generated
+10
@@ -17,6 +17,7 @@
|
|||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"framer-motion": "^12.34.3",
|
"framer-motion": "^12.34.3",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
|
"jose": "^6.1.3",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"openai": "^6.25.0",
|
"openai": "^6.25.0",
|
||||||
@@ -6767,6 +6768,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"framer-motion": "^12.34.3",
|
"framer-motion": "^12.34.3",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
|
"jose": "^6.1.3",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"openai": "^6.25.0",
|
"openai": "^6.25.0",
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function AchievementsPage() {
|
|||||||
setStats(data.stats);
|
setStats(data.stats);
|
||||||
setDecisions(data.decisions);
|
setDecisions(data.decisions);
|
||||||
setContracts(data.contracts);
|
setContracts(data.contracts);
|
||||||
} catch { /* ignore */ }
|
} catch (e) { console.error("AchievementsPage: fetch failed:", e); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
})();
|
})();
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { apiHandler, ApiError } from "@/lib/api";
|
import { apiHandler, ApiError } from "@/lib/api";
|
||||||
|
import { signToken, setAuthCookie } from "@/lib/auth";
|
||||||
|
|
||||||
export const POST = apiHandler(async (req) => {
|
export const POST = apiHandler(async (req) => {
|
||||||
const { username, password } = await req.json();
|
const { username, password } = await req.json();
|
||||||
@@ -14,9 +15,12 @@ export const POST = apiHandler(async (req) => {
|
|||||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||||
if (!valid) throw new ApiError("用户名或密码错误", 401);
|
if (!valid) throw new ApiError("用户名或密码错误", 401);
|
||||||
|
|
||||||
return NextResponse.json({
|
const token = await signToken(user.id);
|
||||||
|
const res = NextResponse.json({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return setAuthCookie(res, token);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client";
|
|||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { apiHandler, ApiError } from "@/lib/api";
|
import { apiHandler, ApiError } from "@/lib/api";
|
||||||
import { validateUsername, validatePassword } from "@/lib/validation";
|
import { validateUsername, validatePassword } from "@/lib/validation";
|
||||||
|
import { signToken, setAuthCookie } from "@/lib/auth";
|
||||||
|
|
||||||
export const POST = apiHandler(async (req) => {
|
export const POST = apiHandler(async (req) => {
|
||||||
const { username, password, avatar } = await req.json();
|
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,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return setAuthCookie(res, token);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||||
throw new ApiError("用户名已被注册", 409);
|
throw new ApiError("用户名已被注册", 409);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireMembership } from "@/lib/blindbox";
|
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) => {
|
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 不能为空");
|
if (!roomId || typeof roomId !== "string") throw new ApiError("roomId 不能为空");
|
||||||
|
|
||||||
await requireMembership(roomId, userId);
|
await requireMembership(roomId, userId);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
import { apiHandler, ApiError } from "@/lib/api";
|
||||||
import { refinePlan } from "@/lib/ai";
|
import { refinePlan } from "@/lib/ai";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
|
||||||
export const POST = apiHandler(async (req) => {
|
export const POST = apiHandler(async (req) => {
|
||||||
const { userId, instruction, days } = await req.json();
|
await getAuthUserId(req);
|
||||||
requireUserId(userId);
|
const { instruction, days } = await req.json();
|
||||||
if (!instruction?.trim()) throw new ApiError("指令不能为空", 400);
|
if (!instruction?.trim()) throw new ApiError("指令不能为空", 400);
|
||||||
if (!Array.isArray(days) || days.length === 0) throw new ApiError("days 无效", 400);
|
if (!Array.isArray(days) || days.length === 0) throw new ApiError("days 无效", 400);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireMembership } from "@/lib/blindbox";
|
import { requireMembership } from "@/lib/blindbox";
|
||||||
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
import { apiHandler, ApiError } from "@/lib/api";
|
||||||
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
|
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
|
||||||
interface AvailableTime {
|
interface AvailableTime {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -10,9 +11,9 @@ interface AvailableTime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const POST = apiHandler(async (req) => {
|
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 不能为空");
|
if (!roomId) throw new ApiError("roomId 不能为空");
|
||||||
|
|
||||||
await requireMembership(roomId, userId);
|
await requireMembership(roomId, userId);
|
||||||
@@ -27,7 +28,7 @@ export const POST = apiHandler(async (req) => {
|
|||||||
throw new ApiError("请选择有效的可用时间");
|
throw new ApiError("请选择有效的可用时间");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await runPlanGeneration(roomId, userId!, at);
|
const result = await runPlanGeneration(roomId, userId, at);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id: result.id,
|
id: result.id,
|
||||||
@@ -69,14 +70,15 @@ function computeEndTime(planData: string, now: Date): Date | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("computeEndTime failed:", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PATCH = apiHandler(async (req) => {
|
export const PATCH = apiHandler(async (req) => {
|
||||||
const { planId, userId, action, days } = await req.json();
|
const userId = await getAuthUserId(req);
|
||||||
requireUserId(userId);
|
const { planId, action, days } = await req.json();
|
||||||
if (!planId) throw new ApiError("planId 不能为空");
|
if (!planId) throw new ApiError("planId 不能为空");
|
||||||
|
|
||||||
const { prisma } = await import("@/lib/prisma");
|
const { prisma } = await import("@/lib/prisma");
|
||||||
@@ -129,10 +131,9 @@ export const PATCH = apiHandler(async (req) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const GET = apiHandler(async (req) => {
|
export const GET = apiHandler(async (req) => {
|
||||||
|
const userId = await getAuthUserId(req);
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const mode = searchParams.get("mode") || "latest";
|
const mode = searchParams.get("mode") || "latest";
|
||||||
const userId = searchParams.get("userId");
|
|
||||||
requireUserId(userId);
|
|
||||||
|
|
||||||
const { prisma } = await import("@/lib/prisma");
|
const { prisma } = await import("@/lib/prisma");
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ export const GET = apiHandler(async (req) => {
|
|||||||
if (!roomId) throw new ApiError("roomId 不能为空");
|
if (!roomId) throw new ApiError("roomId 不能为空");
|
||||||
|
|
||||||
const plan = await prisma.weekendPlan.findFirst({
|
const plan = await prisma.weekendPlan.findFirst({
|
||||||
where: { roomId, userId: userId!, status: "accepted" },
|
where: { roomId, userId, status: "accepted" },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
select: { id: true, planData: true, endTime: true, createdAt: true },
|
select: { id: true, planData: true, endTime: true, createdAt: true },
|
||||||
});
|
});
|
||||||
@@ -156,7 +157,7 @@ export const GET = apiHandler(async (req) => {
|
|||||||
if (mode === "pending") {
|
if (mode === "pending") {
|
||||||
const plans = await prisma.weekendPlan.findMany({
|
const plans = await prisma.weekendPlan.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: userId!,
|
userId,
|
||||||
status: "accepted",
|
status: "accepted",
|
||||||
endTime: { not: null, lt: new Date() },
|
endTime: { not: null, lt: new Date() },
|
||||||
},
|
},
|
||||||
@@ -190,7 +191,7 @@ export const GET = apiHandler(async (req) => {
|
|||||||
if (mode === "history") {
|
if (mode === "history") {
|
||||||
const plans = await prisma.weekendPlan.findMany({
|
const plans = await prisma.weekendPlan.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: userId!,
|
userId,
|
||||||
status: { in: ["completed", "expired"] },
|
status: { in: ["completed", "expired"] },
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
import { requireMembership } from "@/lib/blindbox";
|
import { requireMembership } from "@/lib/blindbox";
|
||||||
import { requireUserId } from "@/lib/api";
|
|
||||||
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
|
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
|
||||||
function encodeSSE(event: string, data: string): string {
|
function encodeSSE(event: string, data: string): string {
|
||||||
return `event: ${event}\ndata: ${data}\n\n`;
|
return `event: ${event}\ndata: ${data}\n\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request): Promise<Response> {
|
export async function POST(req: NextRequest): Promise<Response> {
|
||||||
let roomId: string;
|
let roomId: string;
|
||||||
let userId: string;
|
let userId: string;
|
||||||
let availableTime: { date: string; startHour: number; endHour: number };
|
let availableTime: { date: string; startHour: number; endHour: number };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
userId = await getAuthUserId(req);
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
roomId = body.roomId;
|
roomId = body.roomId;
|
||||||
userId = body.userId;
|
|
||||||
availableTime = body.availableTime;
|
availableTime = body.availableTime;
|
||||||
|
|
||||||
requireUserId(userId);
|
|
||||||
if (!roomId) {
|
if (!roomId) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "roomId 不能为空" }),
|
JSON.stringify({ error: "roomId 不能为空" }),
|
||||||
@@ -41,8 +42,9 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : "请求参数错误";
|
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 }), {
|
return new Response(JSON.stringify({ error: message }), {
|
||||||
status: 400,
|
status,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,7 +57,7 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runPlanGeneration(roomId!, userId!, availableTime!, (message) => {
|
const result = await runPlanGeneration(roomId, userId, availableTime, (message) => {
|
||||||
push("status", message);
|
push("status", message);
|
||||||
});
|
});
|
||||||
push("plan", JSON.stringify({ id: result.id, days: result.days, createdAt: result.createdAt }));
|
push("plan", JSON.stringify({ id: result.id, days: result.days, createdAt: result.createdAt }));
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const POST = apiHandler(async (req) => {
|
|||||||
reason: alt.reason,
|
reason: alt.reason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch { /* ignore, use fallback */ }
|
} catch (e) { console.error("suggest-item: POI search failed, using fallback:", e); }
|
||||||
return {
|
return {
|
||||||
activity: alt.activity,
|
activity: alt.activity,
|
||||||
poi: alt.searchQuery,
|
poi: alt.searchQuery,
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireMembership } from "@/lib/blindbox";
|
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";
|
import { tagIdea } from "@/lib/ai";
|
||||||
|
|
||||||
export const POST = apiHandler(async (req) => {
|
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);
|
await requireMembership(roomId, userId);
|
||||||
|
|
||||||
// Find all untagged ideas in this room (any member's ideas)
|
// Find all untagged ideas in this room (any member's ideas)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getRoomByCode, requireMembership } from "@/lib/blindbox";
|
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 }) => {
|
export const GET = apiHandler(async (_req, { params }) => {
|
||||||
const { code } = await params;
|
const { code } = await params;
|
||||||
@@ -27,10 +28,9 @@ export const GET = apiHandler(async (_req, { params }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const PATCH = apiHandler(async (req, { params }) => {
|
export const PATCH = apiHandler(async (req, { params }) => {
|
||||||
|
const userId = await getAuthUserId(req);
|
||||||
const { code } = await params;
|
const { code } = await params;
|
||||||
const { userId, city, address, lat, lng } = await req.json();
|
const { city, address, lat, lng } = await req.json();
|
||||||
|
|
||||||
requireUserId(userId);
|
|
||||||
|
|
||||||
const room = await prisma.blindBoxRoom.findUnique({
|
const room = await prisma.blindBoxRoom.findUnique({
|
||||||
where: { code: code.toUpperCase() },
|
where: { code: code.toUpperCase() },
|
||||||
@@ -67,10 +67,8 @@ export const PATCH = apiHandler(async (req, { params }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const DELETE = apiHandler(async (req, { params }) => {
|
export const DELETE = apiHandler(async (req, { params }) => {
|
||||||
|
const userId = await getAuthUserId(req);
|
||||||
const { code } = await params;
|
const { code } = await params;
|
||||||
const { userId } = await req.json();
|
|
||||||
|
|
||||||
requireUserId(userId);
|
|
||||||
|
|
||||||
const room = await prisma.blindBoxRoom.findUnique({
|
const room = await prisma.blindBoxRoom.findUnique({
|
||||||
where: { code: code.toUpperCase() },
|
where: { code: code.toUpperCase() },
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
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) => {
|
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("请输入房间号");
|
if (!code || typeof code !== "string") throw new ApiError("请输入房间号");
|
||||||
|
|
||||||
const room = await prisma.blindBoxRoom.findUnique({
|
const room = await prisma.blindBoxRoom.findUnique({
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { generateUniqueRoomCode } from "@/lib/blindbox";
|
import { generateUniqueRoomCode } from "@/lib/blindbox";
|
||||||
import { apiHandler, requireUserId, requireUser } from "@/lib/api";
|
import { apiHandler, requireUser } from "@/lib/api";
|
||||||
import { validateRoomName } from "@/lib/validation";
|
import { validateRoomName } from "@/lib/validation";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
|
||||||
export const POST = apiHandler(async (req) => {
|
export const POST = apiHandler(async (req) => {
|
||||||
const { userId, name } = await req.json();
|
const userId = await getAuthUserId(req);
|
||||||
|
const { name } = await req.json();
|
||||||
requireUserId(userId);
|
|
||||||
|
|
||||||
const roomName = validateRoomName(name);
|
const roomName = validateRoomName(name);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
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) => {
|
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({
|
const memberships = await prisma.blindBoxMember.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireMembership } from "@/lib/blindbox";
|
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 { validateIdeaContent, requireString } from "@/lib/validation";
|
||||||
import { tagIdea } from "@/lib/ai";
|
import { tagIdea } from "@/lib/ai";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
|
||||||
const TAG_TIMEOUT_MS = 60_000;
|
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) => {
|
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");
|
requireString(roomId, "roomId");
|
||||||
const trimmedContent = validateIdeaContent(content);
|
const trimmedContent = validateIdeaContent(content);
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ export const POST = apiHandler(async (req) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const GET = 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");
|
const roomId = requireString(req.nextUrl.searchParams.get("roomId"), "roomId");
|
||||||
|
|
||||||
await requireMembership(roomId, userId);
|
await requireMembership(roomId, userId);
|
||||||
@@ -88,9 +89,9 @@ export const GET = apiHandler(async (req) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const PUT = 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");
|
requireString(ideaId, "ideaId");
|
||||||
const trimmedContent = validateIdeaContent(content);
|
const trimmedContent = validateIdeaContent(content);
|
||||||
|
|
||||||
@@ -107,9 +108,9 @@ export const PUT = apiHandler(async (req) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const DELETE = 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");
|
requireString(ideaId, "ideaId");
|
||||||
|
|
||||||
const { count } = await prisma.blindBoxIdea.deleteMany({
|
const { count } = await prisma.blindBoxIdea.deleteMany({
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireMembership } from "@/lib/blindbox";
|
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";
|
import { suggestIdeas } from "@/lib/ai";
|
||||||
|
|
||||||
export const GET = apiHandler(async (req) => {
|
export const GET = apiHandler(async (req) => {
|
||||||
|
const userId = await getAuthUserId(req);
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const roomId = searchParams.get("roomId");
|
const roomId = searchParams.get("roomId");
|
||||||
const userId = searchParams.get("userId");
|
|
||||||
|
|
||||||
requireUserId(userId);
|
|
||||||
if (!roomId) throw new ApiError("roomId 不能为空");
|
if (!roomId) throw new ApiError("roomId 不能为空");
|
||||||
|
|
||||||
await requireMembership(roomId, userId!);
|
await requireMembership(roomId, userId);
|
||||||
|
|
||||||
const recentIdeas = await prisma.blindBoxIdea.findMany({
|
const recentIdeas = await prisma.blindBoxIdea.findMany({
|
||||||
where: { roomId, status: "in_pool" },
|
where: { roomId, status: "in_pool" },
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
const status = await buildRoomStatus(id);
|
const status = await buildRoomStatus(id);
|
||||||
if (status && alive) send(status);
|
if (status && alive) send(status);
|
||||||
} catch {
|
} catch (e) {
|
||||||
/* ignore transient read errors */
|
console.error("SSE: transient read error:", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
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) => {
|
export const GET = apiHandler(async (req) => {
|
||||||
const userId = req.nextUrl.searchParams.get("userId");
|
const userId = await getAuthUserId(req);
|
||||||
requireUserId(userId);
|
|
||||||
|
|
||||||
const [decisions, contracts] = await Promise.all([
|
const [decisions, contracts] = await Promise.all([
|
||||||
prisma.decision.findMany({
|
prisma.decision.findMany({
|
||||||
where: { userId: userId! },
|
where: { userId },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 50,
|
take: 50,
|
||||||
}),
|
}),
|
||||||
prisma.weekendPlan.findMany({
|
prisma.weekendPlan.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: userId!,
|
userId,
|
||||||
status: { in: ["completed", "expired"] },
|
status: { in: ["completed", "expired"] },
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { Prisma } from "@prisma/client";
|
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) => {
|
export const GET = apiHandler(async (req) => {
|
||||||
const userId = req.nextUrl.searchParams.get("userId");
|
const userId = await getAuthUserId(req);
|
||||||
if (!userId) return NextResponse.json([]);
|
|
||||||
|
|
||||||
const favorites = await prisma.favorite.findMany({
|
const favorites = await prisma.favorite.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -23,9 +23,9 @@ export const GET = apiHandler(async (req) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const POST = 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") {
|
if (!restaurant?.id || typeof restaurant.id !== "string") {
|
||||||
throw new ApiError("缺少必要字段");
|
throw new ApiError("缺少必要字段");
|
||||||
}
|
}
|
||||||
@@ -53,9 +53,9 @@ export const POST = apiHandler(async (req) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const DELETE = 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("缺少必要字段");
|
if (!favoriteId) throw new ApiError("缺少必要字段");
|
||||||
|
|
||||||
const fav = await prisma.favorite.findUnique({ where: { id: favoriteId } });
|
const fav = await prisma.favorite.findUnique({ where: { id: favoriteId } });
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
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;
|
const MAX_HISTORY = 50;
|
||||||
|
|
||||||
export const GET = apiHandler(async (req) => {
|
export const GET = apiHandler(async (req) => {
|
||||||
const userId = req.nextUrl.searchParams.get("userId");
|
const userId = await getAuthUserId(req);
|
||||||
if (!userId) return NextResponse.json([]);
|
|
||||||
|
|
||||||
const decisions = await prisma.decision.findMany({
|
const decisions = await prisma.decision.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -28,10 +28,9 @@ export const GET = apiHandler(async (req) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const POST = apiHandler(async (req) => {
|
export const POST = apiHandler(async (req) => {
|
||||||
const { userId, roomId, restaurant, matchType, participants } =
|
const userId = await getAuthUserId(req);
|
||||||
await req.json();
|
const { roomId, restaurant, matchType, participants } = await req.json();
|
||||||
|
|
||||||
requireUserId(userId);
|
|
||||||
if (!roomId || !restaurant || !matchType) {
|
if (!roomId || !restaurant || !matchType) {
|
||||||
throw new ApiError("缺少必要字段");
|
throw new ApiError("缺少必要字段");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,29 @@ import { NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import bcrypt from "bcryptjs";
|
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 { validateUsername, validatePassword, validateEmail } from "@/lib/validation";
|
||||||
|
import { getAuthUserId } from "@/lib/auth";
|
||||||
|
|
||||||
export const GET = apiHandler(async (req) => {
|
export const GET = apiHandler(async (req) => {
|
||||||
const userId = req.nextUrl.searchParams.get("id");
|
// GET still allows querying by id param (for public profile viewing)
|
||||||
if (!userId) return NextResponse.json(null);
|
// 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);
|
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 = {};
|
let preferences = {};
|
||||||
try { preferences = JSON.parse(user.preferences); } catch { /* fallback */ }
|
try { preferences = JSON.parse(user.preferences); } catch { /* fallback */ }
|
||||||
@@ -21,18 +33,17 @@ export const GET = apiHandler(async (req) => {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
email: user.email,
|
email: isOwner ? user.email : undefined,
|
||||||
preferences,
|
preferences: isOwner ? preferences : undefined,
|
||||||
createdAt: user.createdAt.toISOString(),
|
createdAt: user.createdAt.toISOString(),
|
||||||
decisionCount,
|
decisionCount,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export const PUT = apiHandler(async (req) => {
|
export const PUT = apiHandler(async (req) => {
|
||||||
|
const userId = await getAuthUserId(req);
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { userId } = body;
|
|
||||||
|
|
||||||
requireUserId(userId);
|
|
||||||
const existing = await requireUser(userId);
|
const existing = await requireUser(userId);
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ export default function BlindboxRoomPage() {
|
|||||||
setMyIdeas(data.myIdeas ?? []);
|
setMyIdeas(data.myIdeas ?? []);
|
||||||
setDrawnHistory(data.drawn ?? []);
|
setDrawnHistory(data.drawn ?? []);
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch (e) { console.error("fetchIdeas failed:", e); }
|
||||||
}, [room]);
|
}, [room]);
|
||||||
|
|
||||||
const fetchSuggestions = useCallback(async () => {
|
const fetchSuggestions = useCallback(async () => {
|
||||||
@@ -202,7 +202,7 @@ export default function BlindboxRoomPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch (e) { console.error("fetchSuggestions failed:", e); }
|
||||||
setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4));
|
setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4));
|
||||||
setSuggestionsSource("static");
|
setSuggestionsSource("static");
|
||||||
setSuggestionsLoading(false);
|
setSuggestionsLoading(false);
|
||||||
@@ -230,7 +230,7 @@ export default function BlindboxRoomPage() {
|
|||||||
endTime: data.plan.endTime ?? null,
|
endTime: data.plan.endTime ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch (e) { console.error("fetchAcceptedPlan failed:", e); }
|
||||||
}, [room]);
|
}, [room]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -251,7 +251,7 @@ export default function BlindboxRoomPage() {
|
|||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.pending?.length) setPendingContracts(data.pending);
|
if (data.pending?.length) setPendingContracts(data.pending);
|
||||||
} catch { /* ignore */ }
|
} catch (e) { console.error("fetchPendingContracts failed:", e); }
|
||||||
})();
|
})();
|
||||||
}, [isMember]);
|
}, [isMember]);
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ export default function BlindboxRoomPage() {
|
|||||||
fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`)
|
fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => { if (d.pending?.length) setPendingContracts(d.pending); })
|
.then((d) => { if (d.pending?.length) setPendingContracts(d.pending); })
|
||||||
.catch(() => {});
|
.catch((e) => { console.error("refreshPendingContracts failed:", e); });
|
||||||
}
|
}
|
||||||
}, ms);
|
}, ms);
|
||||||
|
|
||||||
@@ -314,7 +314,7 @@ export default function BlindboxRoomPage() {
|
|||||||
setIsMember(true);
|
setIsMember(true);
|
||||||
fetchRoom();
|
fetchRoom();
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch (e) { console.error("handleJoinRoom failed:", e); }
|
||||||
finally { setJoiningRoom(false); }
|
finally { setJoiningRoom(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1151,7 +1151,7 @@ export default function BlindboxRoomPage() {
|
|||||||
days: planDays,
|
days: planDays,
|
||||||
endTime: data.endTime ?? null,
|
endTime: data.endTime ?? null,
|
||||||
});
|
});
|
||||||
} catch { /* best-effort */ }
|
} catch (e) { console.error("acceptPlan failed:", e); }
|
||||||
}
|
}
|
||||||
toast.show("契约已接受!");
|
toast.show("契约已接受!");
|
||||||
timersRef.current.push(setTimeout(() => {
|
timersRef.current.push(setTimeout(() => {
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ export default function PanicPage() {
|
|||||||
const data: LocationSuggestion[] = await res.json();
|
const data: LocationSuggestion[] = await res.json();
|
||||||
setSuggestions(Array.isArray(data) ? data : []);
|
setSuggestions(Array.isArray(data) ? data : []);
|
||||||
setShowSuggestions(Array.isArray(data) && data.length > 0);
|
setShowSuggestions(Array.isArray(data) && data.length > 0);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("PanicPage: fetchSuggestions failed:", e);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
} finally {
|
} finally {
|
||||||
setFetchingSuggestions(false);
|
setFetchingSuggestions(false);
|
||||||
@@ -178,7 +179,8 @@ export default function PanicPage() {
|
|||||||
try {
|
try {
|
||||||
await joinRoom(roomCode, getUserId());
|
await joinRoom(roomCode, getUserId());
|
||||||
router.push(`/room/${roomCode}`);
|
router.push(`/room/${roomCode}`);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("PanicPage: handleJoin failed:", e);
|
||||||
setError("房间不存在,请检查房间号");
|
setError("房间不存在,请检查房间号");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ export default function ProfilePage() {
|
|||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((e) => {
|
||||||
|
console.error("ProfilePage: fetch user failed:", e);
|
||||||
setProfile({ ...cached });
|
setProfile({ ...cached });
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -93,7 +94,7 @@ export default function ProfilePage() {
|
|||||||
fetch(`/api/user/favorite?userId=${userId}`)
|
fetch(`/api/user/favorite?userId=${userId}`)
|
||||||
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
||||||
.then((data) => setFavorites(Array.isArray(data) ? data : []))
|
.then((data) => setFavorites(Array.isArray(data) ? data : []))
|
||||||
.catch(() => {})
|
.catch((e) => { console.error("ProfilePage: fetch favorites failed:", e); })
|
||||||
.finally(() => setFavLoading(false));
|
.finally(() => setFavLoading(false));
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
@@ -226,8 +227,8 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = async () => {
|
||||||
logout();
|
await logout();
|
||||||
router.push("/");
|
router.push("/");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ function PoiSearchField({ poi, address, onSelect, location }: PoiSearchFieldProp
|
|||||||
setSuggestions(data);
|
setSuggestions(data);
|
||||||
setOpen(data.length > 0);
|
setOpen(data.length > 0);
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch (e) { console.error("PoiSearchField fetch failed:", e); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
}, 400);
|
}, 400);
|
||||||
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function ContractCompletionModal({
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ planId: contract.id, userId, action }),
|
body: JSON.stringify({ planId: contract.id, userId, action }),
|
||||||
});
|
});
|
||||||
} catch { /* best-effort */ }
|
} catch (e) { console.error("ContractCompletionModal: submit failed:", e); }
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
if (current < contracts.length - 1) {
|
if (current < contracts.length - 1) {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function MatchResult({
|
|||||||
matchType,
|
matchType,
|
||||||
participants: userCount,
|
participants: userCount,
|
||||||
}),
|
}),
|
||||||
}).catch(() => {});
|
}).catch((e) => { console.error("MatchResult: save history failed:", e); });
|
||||||
}, [registered, userId, roomId, restaurant, matchType, userCount]);
|
}, [registered, userId, roomId, restaurant, matchType, userCount]);
|
||||||
|
|
||||||
const handleOpenShareCard = useCallback(() => {
|
const handleOpenShareCard = useCallback(() => {
|
||||||
@@ -136,8 +136,8 @@ export default function MatchResult({
|
|||||||
setFavorited(true);
|
setFavorited(true);
|
||||||
toast.show("已收藏");
|
toast.show("已收藏");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
/* ignore */
|
console.error("MatchResult: handleFavorite failed:", e);
|
||||||
}
|
}
|
||||||
setFavLoading(false);
|
setFavLoading(false);
|
||||||
}, [registered, userId, restaurant, favorited, favLoading, toast]);
|
}, [registered, userId, restaurant, favorited, favLoading, toast]);
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
|
|||||||
body: JSON.stringify({ userId: getUserId(), restaurant }),
|
body: JSON.stringify({ userId: getUserId(), restaurant }),
|
||||||
});
|
});
|
||||||
if (res.ok) setFavorited(true);
|
if (res.ok) setFavorited(true);
|
||||||
} catch {}
|
} catch (e) { console.error("RestaurantCard: handleFavorite failed:", e); }
|
||||||
}, [restaurant, favorited]);
|
}, [restaurant, favorited]);
|
||||||
const openLink = useCallback(
|
const openLink = useCallback(
|
||||||
(url: string) => (e: React.MouseEvent | React.TouchEvent) => {
|
(url: string) => (e: React.MouseEvent | React.TouchEvent) => {
|
||||||
|
|||||||
@@ -227,8 +227,8 @@ export default function SwipeDeck({
|
|||||||
if (data.match != null) {
|
if (data.match != null) {
|
||||||
setLocalMatchId(data.match);
|
setLocalMatchId(data.match);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Polling will catch match state
|
console.error("SwipeDeck: sendSwipe failed:", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -273,8 +273,8 @@ export default function SwipeDeck({
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ userId, restaurantId: lastRid }),
|
body: JSON.stringify({ userId, restaurantId: lastRid }),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Best-effort
|
console.error("SwipeDeck: handleUndo failed:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSwipeHistory((h) => h.slice(0, -1));
|
setSwipeHistory((h) => h.slice(0, -1));
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ async function reverseGeocode(lat: number, lng: number): Promise<string | null>
|
|||||||
const res = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
|
const res = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.name || data.formatted || null;
|
return data.name || data.formatted || null;
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("reverseGeocode failed:", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export function useRoomPolling(roomId: string | undefined) {
|
|||||||
if (parsed.roomId) {
|
if (parsed.roomId) {
|
||||||
mutate(parsed, { revalidate: false });
|
mutate(parsed, { revalidate: false });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
/* malformed message */
|
console.error("useRoomPolling: malformed SSE message:", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+12
-6
@@ -132,7 +132,8 @@ export async function tagIdea(content: string): Promise<IdeaTags | null> {
|
|||||||
searchQuery: parsed.searchQuery,
|
searchQuery: parsed.searchQuery,
|
||||||
searchType: parsed.searchType,
|
searchType: parsed.searchType,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("tagIdea failed:", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,7 +171,8 @@ export async function suggestIdeas(existingIdeas: string[]): Promise<string[]> {
|
|||||||
return parsed.suggestions
|
return parsed.suggestions
|
||||||
.filter((s: unknown) => typeof s === "string" && s.length > 0)
|
.filter((s: unknown) => typeof s === "string" && s.length > 0)
|
||||||
.slice(0, 4);
|
.slice(0, 4);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("suggestIdeas failed:", e);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +238,8 @@ ${Object.entries(ctx.candidates)
|
|||||||
})),
|
})),
|
||||||
summary: String(parsed.summary ?? ""),
|
summary: String(parsed.summary ?? ""),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("generateSchedule failed:", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,7 +287,8 @@ export async function refinePlan(
|
|||||||
})) return null;
|
})) return null;
|
||||||
|
|
||||||
return result as import("@/types").WeekendPlanData[];
|
return result as import("@/types").WeekendPlanData[];
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("refinePlan failed:", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,7 +341,8 @@ export async function suggestAlternativeItems(
|
|||||||
typeof (a as Record<string, unknown>).searchQuery === "string",
|
typeof (a as Record<string, unknown>).searchQuery === "string",
|
||||||
)
|
)
|
||||||
.slice(0, 3) as Array<{ activity: string; searchQuery: string; reason: string }>;
|
.slice(0, 3) as Array<{ activity: string; searchQuery: string; reason: string }>;
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("suggestAlternativeItems failed:", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,7 +442,8 @@ export async function runAgentLoop(
|
|||||||
let args: Record<string, unknown>;
|
let args: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
args = JSON.parse(toolCall.function.arguments);
|
args = JSON.parse(toolCall.function.arguments);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("runAgentLoop: failed to parse tool arguments:", e);
|
||||||
args = {};
|
args = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -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 {
|
export function requireUserId(value: unknown): string {
|
||||||
if (!value || typeof value !== "string") {
|
if (!value || typeof value !== "string") {
|
||||||
throw new ApiError("请先登录", 401);
|
throw new ApiError("请先登录", 401);
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
return new SignJWT({ sub: userId })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(`${TOKEN_MAX_AGE}s`)
|
||||||
|
.sign(getSecret());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyToken(token: string): Promise<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string | null> {
|
||||||
|
const token = req.cookies.get(COOKIE_NAME)?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
return await verifyToken(token);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -319,7 +319,8 @@ function buildAgentTools(
|
|||||||
try {
|
try {
|
||||||
const pois = await searchPois(query, searchType, lat, lng);
|
const pois = await searchPois(query, searchType, lat, lng);
|
||||||
return JSON.stringify(pois);
|
return JSON.stringify(pois);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("searchPoiTool failed:", e);
|
||||||
return JSON.stringify([]);
|
return JSON.stringify([]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -369,7 +370,8 @@ function buildAgentTools(
|
|||||||
const distanceKm = Math.round(Number(data.route.distance) / 100) / 10;
|
const distanceKm = Math.round(Number(data.route.distance) / 100) / 10;
|
||||||
const { description, mode } = parseTransitSegments(transit.segments ?? []);
|
const { description, mode } = parseTransitSegments(transit.segments ?? []);
|
||||||
return JSON.stringify({ durationMin, distanceKm, description, mode });
|
return JSON.stringify({ durationMin, distanceKm, description, mode });
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("getTravelTimeTool failed:", e);
|
||||||
return JSON.stringify({ error: "路线查询失败" });
|
return JSON.stringify({ error: "路线查询失败" });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -535,7 +537,8 @@ async function runLegacyPlanGeneration(
|
|||||||
try {
|
try {
|
||||||
const pois = await searchPois(idea.searchQuery, idea.searchType, room.lat, room.lng);
|
const pois = await searchPois(idea.searchQuery, idea.searchType, room.lat, room.lng);
|
||||||
return { query: idea.searchQuery, pois };
|
return { query: idea.searchQuery, pois };
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error(`searchPois failed for "${idea.searchQuery}":`, e);
|
||||||
return { query: idea.searchQuery, pois: [] };
|
return { query: idea.searchQuery, pois: [] };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -560,7 +563,8 @@ async function runLegacyPlanGeneration(
|
|||||||
try {
|
try {
|
||||||
const pois = await searchPois(idea.searchQuery, idea.searchType, anchorLat, anchorLng);
|
const pois = await searchPois(idea.searchQuery, idea.searchType, anchorLat, anchorLng);
|
||||||
return { query: idea.searchQuery, pois };
|
return { query: idea.searchQuery, pois };
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error(`searchPois (category) failed for "${idea.searchQuery}":`, e);
|
||||||
return { query: idea.searchQuery, pois: [] };
|
return { query: idea.searchQuery, pois: [] };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -643,7 +647,8 @@ async function queryTransit(
|
|||||||
const transit = data.route.transits[0];
|
const transit = data.route.transits[0];
|
||||||
const { description } = parseTransitSegments(transit.segments ?? []);
|
const { description } = parseTransitSegments(transit.segments ?? []);
|
||||||
return { durationMin: Math.ceil(Number(transit.duration) / 60), description };
|
return { durationMin: Math.ceil(Number(transit.duration) / 60), description };
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("queryTransit failed:", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -770,7 +775,8 @@ export async function runPlanGeneration(
|
|||||||
onProgress,
|
onProgress,
|
||||||
);
|
);
|
||||||
days = agentResult.days;
|
days = agentResult.days;
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("runAgentPlanGeneration failed, falling back to legacy:", e);
|
||||||
onProgress?.("使用备用方案规划...");
|
onProgress?.("使用备用方案规划...");
|
||||||
const legacyResult = await runLegacyPlanGeneration(
|
const legacyResult = await runLegacyPlanGeneration(
|
||||||
{ lat: room.lat, lng: room.lng },
|
{ lat: room.lat, lng: room.lng },
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export async function loadImageAsDataUrl(src: string): Promise<string | null> {
|
|||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d")!;
|
||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0);
|
||||||
return canvas.toDataURL("image/jpeg", 0.85);
|
return canvas.toDataURL("image/jpeg", 0.85);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error("proxyToDataUrl failed:", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-5
@@ -102,21 +102,45 @@ export async function getRoomData(
|
|||||||
const room = await prisma.room.findUnique({ where: { id: roomId } });
|
const room = await prisma.room.findUnique({ where: { id: roomId } });
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
if (room.expiresAt < new Date()) {
|
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 null;
|
||||||
}
|
}
|
||||||
return normalize(JSON.parse(room.data));
|
return normalize(JSON.parse(room.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomic read-modify-write within a Prisma transaction.
|
* Per-room mutex to serialize concurrent read-modify-write operations.
|
||||||
* Prevents race conditions when multiple users swipe concurrently.
|
* 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<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
function withRoomLock<T>(roomId: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
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(
|
export async function atomicUpdateRoom(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
updater: (data: RoomData) => RoomData,
|
updater: (data: RoomData) => RoomData,
|
||||||
): Promise<RoomData | null> {
|
): Promise<RoomData | null> {
|
||||||
return prisma.$transaction(async (tx) => {
|
return withRoomLock(roomId, () =>
|
||||||
|
prisma.$transaction(async (tx) => {
|
||||||
const room = await tx.room.findUnique({ where: { id: roomId } });
|
const room = await tx.room.findUnique({ where: { id: roomId } });
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
@@ -129,5 +153,6 @@ export async function atomicUpdateRoom(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -55,8 +55,14 @@ export function setCachedPreferences(prefs: UserPreferences): void {
|
|||||||
localStorage.setItem("nowhatever_preferences", JSON.stringify(prefs));
|
localStorage.setItem("nowhatever_preferences", JSON.stringify(prefs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout(): void {
|
export async function logout(): Promise<void> {
|
||||||
if (typeof window === "undefined") return;
|
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(PROFILE_KEY);
|
||||||
localStorage.removeItem("nowhatever_preferences");
|
localStorage.removeItem("nowhatever_preferences");
|
||||||
const newId = crypto.randomUUID();
|
const newId = crypto.randomUUID();
|
||||||
|
|||||||
Reference in New Issue
Block a user