Compare commits

...

66 Commits

Author SHA1 Message Date
kurihada 00683e51c4 修复无 buildx 环境下构建回退逻辑 2026-03-03 18:15:23 +08:00
kurihada 5b5ecd1b29 CI 提速:构建阶段启用 buildx 缓存并移除重复类型检查 2026-03-03 18:07:26 +08:00
kurihada 8ab8557ef1 CI 提速:主链路覆盖率改为可选参数 2026-03-03 17:54:40 +08:00
kurihada 91dcb735cd CI 依赖镜像流程补充 Prisma generate 修复类型检查失败 2026-03-03 17:06:40 +08:00
kurihada 57dd743fc1 CI 提速:引入按 lock hash 的依赖镜像缓存 2026-03-03 16:54:43 +08:00
kurihada a61081fca9 修复 CI 缓存脚本变量展开错误并恢复 node_modules 命中 2026-03-03 16:38:31 +08:00
kurihada 6490cd068e CI 提速:缓存 node_modules 并改用 git archive 传输工作区 2026-03-03 16:28:10 +08:00
kurihada 303e32f599 测试环境补充 JWT_SECRET 避免登录注册用例在 CI 失败 2026-03-03 16:19:51 +08:00
kurihada b117a3694d CI 提速:合并门禁容器执行并拆分 Smoke/全量 E2E 2026-03-03 16:09:24 +08:00
kurihada 0b28c94762 Jenkins 镜像预拉取增加超时重试与缓存检测 2026-03-03 15:30:16 +08:00
kurihada 35aa7fb6bc 修复 Jenkins 容器内看不到 lockfile 的问题 2026-03-03 15:17:53 +08:00
kurihada f892c92608 Jenkins 改为容器化执行 Node 质量门禁与 E2E 2026-03-03 15:13:50 +08:00
kurihada 17cf80bed3 Jenkins 增加 Node 运行时自检与回退 2026-03-03 15:04:59 +08:00
kurihada cf7a7bd2e0 Jenkins 失败时归档 E2E 测试产物 2026-03-03 14:54:59 +08:00
kurihada b28c61ea3a 跑通 E2E 并修正执行链路与文档记录 2026-03-03 14:37:55 +08:00
kurihada c4c1cb400b 接入 E2E 门禁并固定 Playwright 执行版本 2026-03-03 14:06:40 +08:00
kurihada fe3016d710 新增 E2E 测试配置与用例并更新测试补充文档 2026-03-03 13:56:07 +08:00
kurihada 482307f2f4 清理 lint 剩余告警并更新审计文档状态 2026-03-03 13:53:19 +08:00
kurihada d91ea8c1e8 补充核心回归测试并完成补测清单 T1-T4 2026-03-03 13:33:42 +08:00
kurihada 3cd4c26d3c 新增测试补充执行文档并定义补测清单 2026-03-03 13:26:08 +08:00
kurihada 22610f0b59 统一盲盒前端 API 调用层并收敛错误处理 2026-03-03 13:17:42 +08:00
kurihada 532d8ff7ad 统一盲盒请求契约并移除冗余 userId 参数 2026-03-03 13:11:33 +08:00
kurihada 4a5ed3b25a 优化房间点赞同步为增量更新策略 2026-03-03 13:07:22 +08:00
kurihada 325b7b5742 优化计划查询避免 N+1 数据库访问 2026-03-03 13:05:29 +08:00
kurihada 41ac21ea12 完善 CI 质量门禁并启用覆盖率阈值 2026-03-03 13:02:31 +08:00
kurihada 4cd593bc30 修复定时器与动画回调清理不完整问题 2026-03-03 12:27:34 +08:00
kurihada 45dbac1935 修复 lint 阻塞错误并恢复门禁 2026-03-03 12:25:03 +08:00
kurihada 52b87abee3 修复 TypeScript 基线并补齐测试类型 2026-03-03 12:21:02 +08:00
kurihada 673dc1177e 修复计划接受流程的前后端状态不一致 2026-03-03 12:16:04 +08:00
kurihada 67fdf7427a 修复 SSE 成员校验未生效的问题 2026-03-03 12:14:38 +08:00
kurihada 724be69c76 修复 Jenkinsfile 中敏感信息硬编码问题 2026-03-03 12:12:17 +08:00
kurihada 5a6d457a87 修复 API 500 响应泄露内部错误细节 2026-03-03 12:11:02 +08:00
kurihada 486193c823 修复 suggest-item 接口缺失鉴权并补充测试 2026-03-03 12:08:35 +08:00
kurihada 8b4ab415fd fix: validate swipe restaurant ids against room candidates 2026-03-03 12:06:03 +08:00
kurihada f3d8a58603 fix: unify panic room code format and validate room join id 2026-03-03 12:04:00 +08:00
kurihada 4f4220652e refactor(P2/P3): 完成全部7批重构 — 模块化、SSE退避、无障碍、Zod校验、Server组件、Room关系化
批次A:重命名 + 路由拆分
- store.ts → roomRepository.ts,更新全部 import
- blindbox/plan/route.ts 精简为薄路由,业务逻辑抽取到 planActions.ts / planQueries.ts

批次B:blindboxPlanGen.ts 拆分(710行 → src/lib/plan/)
- agentPlan.ts:Agent 工具调用与系统提示
- legacyPlan.ts:非 Agent 备用生成逻辑
- ideaSelection.ts:Idea 筛选与 Slot 映射
- transitEnrichment.ts:交通信息查询与填充
- index.ts:runPlanGeneration 主入口

批次C:SSE 连接稳定性
- useRoomPolling.ts 加入指数退避重连(上限60s,含Jitter)
- plan/stream/route.ts 添加30s心跳 + abort信号清理

批次D:无障碍修复
- Modal:role=dialog、aria-modal、aria-labelledby
- AuthModal:aria-label关闭按钮、tablist/tab/aria-selected
- PlanItemEditModal、QrInviteModal:补全aria-label
- BlindboxPlan:图标按钮aria-label

批次E:Zod 引入
- src/lib/schemas/ai.ts:AI返回值 Schema(IdeaTagsSchema等5个)
- src/lib/schemas/requests.ts:请求体 Schema
- ai.ts 手工验证替换为 Zod safeParse

批次F:Server Components
- achievements/page.tsx → Server Component + AchievementsClient.tsx
- profile/page.tsx → Server Component + ProfileClient.tsx

批次G:Room 关系化模型
- prisma/schema.prisma:新增 RoomMember、RoomRestaurant、RoomLike、RoomSwipe 4张表
- migration:20260302010000_room_relational_model
- roomRepository.ts 完整重写(关系查询+应用锁)
- buildRoomStatus.ts 适配关系查询

测试:全部329个用例通过,修复68个因auth mock缺失导致的测试失败
2026-03-02 20:27:06 +08:00
kurihada 6bb0e65d4c refactor(P1): 5 项代码质量改进 — 消除重复、拆分巨型组件、统一基础设施
Task 4: 统一 amap.ts 为完整 API 客户端
- 扩展 amap.ts 为统一客户端(amapFetch 8s 超时 + 错误处理)
- 导出 searchPlaceText/searchPlaceAround/getInputTips/reverseGeocode/getTransitDirection
- 精简 4 个 location route 为单行调用,blindboxPlanGen 删除 ~80 行内联 API 代码

Task 2: 抽取 ShareCardShell 消除三兄弟重复
- 新建 ShareCardShell.tsx 共享外框/背景/品牌头/QR 底部
- RestaurantShareCard 406→268 行,BlindboxShareCard 341→173 行,BlindboxPlanShareCard 277→159 行

Task 3: 拆分 BlindboxPlan.tsx (742→371 行)
- 提取 planUtils.ts (guessCategory + formatDuration)
- 提取 PoiSearchField / SortablePlanItem / PlanItemEditModal 三个独立组件

Task 1: 拆分 blindbox/[code]/page.tsx 上帝组件 (1300→509 行)
- 提取 useBlindboxRoom / useBlindboxIdeas / useBlindboxPlan / useBlindboxDraw 四个 hooks
- 提取 BlindboxPoolPhase / BlindboxRevealPhase 两个子组件
- 主页面仅保留 phase 协调 + hook 组装 + 子组件渲染

Task 5: 统一 SWR 数据获取层
- 新建 fetcher.ts (FetchError 携带 status,401 不重试)
- 新建 useBlindboxRooms / useAchievements / useFavorites SWR hooks
- useRoomPolling 改用共享 fetcher
- blindbox 大厅/成就/个人中心页面删除手写 fetch 样板代码
- JWT 过期时自动弹出登录框而非反复重试
2026-03-02 18:05:06 +08:00
kurihada ce76980fe5 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 全景分析文档
2026-03-02 17:24:26 +08:00
kurihada 99120a7042 feat: 交通信息与 AI 解耦,完善出发/回程路线显示
- 从 finalize_plan schema 和 agent 提示词中移除 transit 字段,AI 只负责活动/POI/坐标
- 新增 enrichTransitInfo:计划生成后查询高德 V3 公交 API,计算出发地→首活动、活动间、末活动→出发地三段交通
- parseTransitSegments 增加起终点站显示(去除线路名中的全程终点括号)
- WeekendPlanData 新增 transitFromStart/transitToEnd 字段
- BlindboxPlan 新增出发地和返回出发地交通连接器,传入 startLocationLabel 显示具体地址
- BlindBoxRoom schema 新增 address 字段存完整逆地理地址,city 保留供 API 使用
- 新增 /api/debug/transit 调试端点(仅开发环境)
- agent userPrompt 要求将出发/回程时间计入全天时间预算
2026-03-02 16:35:38 +08:00
kurihada e5a255a49e feat: 行程卡片间显示交通路线描述和时间
- get_travel_time 解析 Amap segments 提取线路名和站数
- PlanItem 新增 transitToNext / transitDescription 字段
- finalize_plan schema 加入 transit_to_next_description
- 修复 Turbopack 中文引号解析报错
- UI 连接器改为两行布局,路线描述与时长分行显示
2026-03-02 14:20:53 +08:00
kurihada 7b6ce22f63 fix: 修复行程卡片间距被 last:mb-0 清零的问题
每个 SortablePlanItem 被 motion.div 包裹,last:mb-0 会对
所有 item 生效导致 mb-5 失效,移除 last:mb-0 即可。
同步优化卡片内部间距、padding、reason 加分隔线。
2026-03-02 13:03:53 +08:00
kurihada 04a45c4894 feat: AI 辅助修改行程(自然语言调整 + 单活动替代推荐)
- 新增 refinePlan / suggestAlternativeItems 到 ai.ts
- 新增 POST /api/blindbox/plan/refine(整体行程调整)
- 新增 POST /api/blindbox/plan/suggest-item(单活动 AI 替代 + POI 搜索)
- BlindboxPlan 底部新增自然语言输入框(方案 A)
- 编辑 modal 内新增 AI 推荐替代方案卡片(方案 B)
- export searchPois 供 suggest-item 路由复用
2026-03-02 12:29:21 +08:00
kurihada 4e6a3e007c feat: 行程活动拖拽排序 + 编辑表单(含高德 POI 搜索)
- 安装 @dnd-kit/core/sortable/utilities/modifiers
- BlindboxPlan: 同天内拖拽排序(PointerSensor + TouchSensor,限垂直轴)
- BlindboxPlan: 点击编辑弹出 sheet modal,支持改时间(type=time)、时长、活动名
- BlindboxPlan: POI 字段改为高德 inputtips 搜索下拉,选中自动填入名称/地址/坐标
- BlindboxPlan: 搜索传入房间坐标 location 参数,结果按距离排序
- BlindboxPlan: 跨天移动通过 select 立即生效
- plan route: 新增 update_plan action,支持 PATCH 保存修改后的 days
- page.tsx: 新增 handlePlanDaysChange,乐观更新本地 state + 失败时回滚
2026-03-02 12:06:50 +08:00
kurihada df2e373beb feat: 改进计划生成体验与 AI 提示词
- 生成中状态改为滚动日志列表,底部新增消息自动滚动
- 返回想法池不再清空计划,pool 页面保留"待确认计划"横幅
- 换方案时才清空旧计划
- 提示词补充午餐/晚餐时间窗口约束(午餐11:30-13:00,晚餐17:30-19:30)
- get_travel_time 从驾车改为公共交通,阈值从30分钟调整为45分钟
2026-03-02 11:26:20 +08:00
kurihada b78063739b fix: 盲盒想法打标改为后台异步,新增 retag 补打接口
- applyTags 改为 fire-and-forget,用户投入想法立即返回(<200ms)
- 打标超时设为 60s,失败静默,由 /api/blindbox/retag 兜底
- 新增 POST /api/blindbox/retag:补打房间内所有无标签想法
2026-03-02 10:55:27 +08:00
kurihada 9d891fb702 feat: 为行程规划 agent 添加 get_travel_time 工具,调用高德驾车 API 验证交通时间
agent 之前完全靠 LLM 猜测地理距离,现在可以查询真实驾车时间,超过 30 分钟会换更近地点。
同时修复 dev 端口未生效的问题(next dev 加 --port 3721)。
2026-03-02 10:21:00 +08:00
kurihada ea49ce6081 chore: 端口从 3000 改为 3721 避免冲突 2026-03-02 01:07:45 +08:00
kurihada 7fd1005e03 feat: 行程规划改用 tool-calling agent,旧逻辑保留为降级方案
ai.ts 新增通用 runAgentLoop(),blindboxPlanGen.ts 拆分为
agent 主路径(list_ideas/search_poi/finalize_plan 三个工具)
和 legacy 降级路径,agent 失败时自动回退。
AI 思考和工具调用实时推送给前端。
2026-03-02 01:07:25 +08:00
kurihada 93499867d5 feat: 改进标签系统 — 新增品类/费用/强度/预约标签,timeSlot 参与选活动
- IdeaCategory 扩展 7→9,新增 experience(体验)和 nature(自然)
- 替换 outdoor boolean 为 costLevel/intensity/needsBooking 三个高价值字段
- AI 标注 prompt 同步更新,行程规划新增强度交替、费用平衡、预约提醒原则
- selectIdeasForSlots 重写为四优先级:timeSlot+category > category > timeSlot > 任意
- 前端想法卡片展示费用/强度/预约标签
2026-03-02 00:03:35 +08:00
kurihada bc2b0e40c6 fix: 修复生产数据库 schema 同步脚本的双重 IF NOT EXISTS bug
prisma db push 生成的 .schema 已自带 CREATE TABLE IF NOT EXISTS,
旧脚本的 sed 替换会生成 IF NOT EXISTS IF NOT EXISTS 导致 SQL 语法错误,
建表全部静默失败。现在只对 CREATE INDEX 做 IF NOT EXISTS 补充。
2026-03-01 22:44:44 +08:00
kurihada dfb7f464a0 fix: 改用 prisma db push 同步生产数据库 schema
旧的 sqlite3 手动同步脚本会静默吞掉建表失败的错误,
导致 BlindBoxMember 等表在生产环境从未创建成功。
改用 prisma db push 做 schema 同步,由 Prisma 自身保证正确性。
2026-03-01 22:12:25 +08:00
kurihada fe9a52e2e4 fix: API 500 错误返回具体原因,便于线上排查 2026-03-01 21:59:23 +08:00
kurihada 0e9d4ae43e fix: 修复 Vitest 4.x 构建兼容性问题
environmentMatchGlobs 在 Vitest 4 中已移除,改用 projects 配置;
排除 vitest.config.ts 不参与 Next.js 类型检查。
2026-03-01 21:51:14 +08:00
kurihada 3ccd1262f9 test: 添加完整测试套件(52 个文件,326 个用例)
基于 Vitest 搭建测试基础设施,覆盖后端纯函数、API 路由、
前端 hooks、UI 组件和页面级集成测试。
2026-02-28 20:19:14 +08:00
kurihada 11eeec868e Revert "feat: 从 SQLite 切换到 PostgreSQL"
This reverts commit 827fb761bd.
2026-02-28 18:27:42 +08:00
kurihada 827fb761bd feat: 从 SQLite 切换到 PostgreSQL
- Prisma provider 改为 postgresql
- docker-compose 新增 postgres 服务(带健康检查)
- Dockerfile 移除 sqlite3/template.db,改用 npm install prisma 做运行时 schema 同步
- docker-entrypoint.sh 简化为 prisma db push + node server.js
2026-02-28 17:47:01 +08:00
kurihada bf806f0891 fix: 改用 sqlite3 原生工具做 schema 同步,替代不可靠的 prisma CLI 方案 2026-02-28 17:33:49 +08:00
kurihada 3e7bb51618 fix: 房间加载失败时显示具体错误原因,便于线上排查 2026-02-28 17:30:09 +08:00
kurihada 039887547d fix: 容器启动时自动同步数据库 schema,解决线上部署后表结构不同步导致的加载失败 2026-02-28 17:24:03 +08:00
kurihada 1e39c72a63 fix(blindbox): 修复房间创建后返回大厅不可见 + 大厅房间列表管理
- 修复房主退出房间时误删整间房的问题,改为返回大厅(房间保留)
- 修复大厅页 fetchRooms 时序依赖导致导航回来不刷新的问题
- fetch 添加 cache:no-store + router.refresh() 确保数据始终最新
- 房间列表增加 max-h 滚动 + 底部渐变遮罩防溢出
- 大厅房间卡片支持内联删除/退出(··· 按钮 → 确认栏)
- rooms API 返回 creatorId 以区分房主/成员操作
2026-02-27 18:38:05 +08:00
kurihada 2d49744dd0 feat(blindbox): AI 计划生成流式进度与渐进式文案
- 新增 runPlanGeneration 与 onProgress 回调 (blindboxPlanGen.ts)
- 新增 POST /api/blindbox/plan/stream 推送 SSE 进度事件
- 前端优先走流式接口,实时展示「分析想法→搜索地点→规划路线→快好了」
- 流式失败时回退普通 POST,客户端轮播进度文案作为后备
- 规划阶段 UI 显示 planStatusMessage 替代静态文案
2026-02-27 17:37:40 +08:00
kurihada 9aee4f0e9b fix(blindbox): 周末契约页输入框无法输入 — 非受控输入 + 全局 input 样式
- 创建/加入房间输入改为 ref + defaultValue,提交时从 DOM 取值,避免受控 state 导致无法打字
- globals: input/textarea 增加 user-select: text !important、touch-action: manipulation
- Input 组件: cursor-text、touch-manipulation、min-w-0、autoComplete=off
2026-02-27 17:27:51 +08:00
kurihada 61ef54b2bd feat: 盲盒想法输入增加 AI 灵感推荐
输入框下方展示灵感建议标签,点击即填入,降低创作门槛:
- 房间有 ≥2 条想法时调用 DeepSeek 生成贴合调性的推荐
- 想法不足或 AI 失败时 fallback 到 18 条静态灵感随机选 4 条
- 提交新想法后自动刷新推荐,支持手动换一批
2026-02-27 16:52:59 +08:00
kurihada 76349f0dcf feat: 接入全站图片资源 + 修复卡片滑动与房间轮询问题
图片资源接入:
- OG/Twitter 社交分享元数据 (og-image.png)
- 错误页插画替换图标 (error-robot.png)
- EmptyState 组件支持 image prop,空状态页面接入插画
- 餐厅图片 fallback 改用 restaurant-fallback.png
- 极速救场/周末契约页面添加 hero 装饰图
- 分享卡片添加背景图层 (share-bg-*.png),通过 base64 预加载
- 更新 App 图标 (apple-touch-icon, icon-192/512)

Bug 修复:
- SwipeDeck: swipe action 从 "nope" 改为 "pass",匹配 API 预期
- SwipeDeck: 用 ref 读取 currentIndex 避免竞态重置(本地滑动后
  被服务端旧 swipeCounts 立即清零)
- SwipeDeck: 卡片 key 加入 isTop 标识,强制 remount 解决
  framer-motion drag 手势在 isTop 切换时不重新初始化的问题
- SwipeableCard: initial 统一为背景位置,确保晋升为顶部卡片时
  有一致的放大动画
- useRoomPolling: roomId 为空时跳过 SWR 和 EventSource
- room page: joinRoom 前 guard roomId,消除退房时 404
- layout: 添加 metadataBase 消除 Next.js OG 图片警告
2026-02-27 16:08:38 +08:00
kurihada 4073be9066 fix: invite 页面补充 Utensils 图标导入,修复编译失败 2026-02-27 11:43:40 +08:00
kurihada 3409456ca5 merge: dev → main (v2.0 周末契约 + 全站重构) 2026-02-27 10:48:13 +08:00
204 changed files with 17123 additions and 3545 deletions
+2
View File
@@ -12,6 +12,8 @@
# testing
/coverage
/playwright-report
/test-results
# next.js
/.next/
+7 -3
View File
@@ -25,6 +25,7 @@ RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache sqlite
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
@@ -35,12 +36,15 @@ COPY --from=builder /app/prisma/template.db ./template.db
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000
ENV PORT=3000
EXPOSE 3721
ENV PORT=3721
ENV HOSTNAME="0.0.0.0"
ENV DATABASE_URL="file:/app/data/prod.db"
CMD ["sh", "-c", "cp -n template.db /app/data/prod.db 2>/dev/null; node server.js"]
CMD ["./docker-entrypoint.sh"]
+8
View File
@@ -0,0 +1,8 @@
ARG CI_IMAGE=mcr.microsoft.com/playwright:v1.51.1-jammy
FROM ${CI_IMAGE}
ARG NPM_REGISTRY=https://registry.npmmirror.com
WORKDIR /workspace
COPY package.json package-lock.json ./
RUN npm config set registry ${NPM_REGISTRY} && npm ci --prefer-offline --no-audit --progress=false
Vendored
+205 -21
View File
@@ -1,14 +1,24 @@
pipeline {
agent any
options {
skipDefaultCheckout(true)
}
parameters {
booleanParam(name: 'RUN_FULL_E2E', defaultValue: false, description: '是否在部署后额外执行全量 E2E(失败仅标记 UNSTABLE')
booleanParam(name: 'RUN_COVERAGE', defaultValue: false, description: '是否在主链路执行覆盖率测试(关闭可提速)')
}
environment {
APP_NAME = 'no-whatever'
AMAP_KEY = '7f6be40a6de3f7fbb7bc3f825b67573b'
DEEPSEEK_KEY = credentials('deepseek-api-key')
APP_NAME = 'no-whatever'
CI_IMAGE = 'mcr.microsoft.com/playwright:v1.51.1-jammy'
NPM_REGISTRY = 'https://registry.npmmirror.com'
BUILDX_CACHE_DIR = '.buildx-cache'
}
triggers {
GenericTrigger(token: 'no-whatever-deploy')
GenericTrigger(tokenCredentialId: 'no-whatever-deploy-token')
}
stages {
@@ -18,30 +28,204 @@ pipeline {
}
}
stage('Runtime Check') {
steps {
sh 'docker --version'
}
}
stage('Prepare Test Images') {
options {
timeout(time: 15, unit: 'MINUTES')
}
steps {
retry(2) {
sh '''
set -e
if docker image inspect ${CI_IMAGE} >/dev/null 2>&1; then
echo "${CI_IMAGE} already exists, skip pull"
else
docker pull ${CI_IMAGE}
fi
'''
}
}
}
stage('Prepare CI Deps Image') {
options {
timeout(time: 20, unit: 'MINUTES')
}
steps {
script {
env.LOCK_HASH = sh(script: "sha256sum package-lock.json | cut -d ' ' -f1", returnStdout: true).trim()
env.CI_DEPS_IMAGE = "${APP_NAME}-ci-deps:${env.LOCK_HASH}"
}
sh '''
set -e
if docker image inspect "${CI_DEPS_IMAGE}" >/dev/null 2>&1; then
echo "deps image cache hit: ${CI_DEPS_IMAGE}"
else
echo "deps image cache miss, building: ${CI_DEPS_IMAGE}"
docker build \
--build-arg CI_IMAGE=${CI_IMAGE} \
--build-arg NPM_REGISTRY=${NPM_REGISTRY} \
-f Dockerfile.ci-deps \
-t "${CI_DEPS_IMAGE}" .
fi
'''
}
}
stage('CI Gate (Lint + Unit + Smoke E2E)') {
options {
timeout(time: 30, unit: 'MINUTES')
}
steps {
sh '''
set -e
rm -rf playwright-report test-results
cid=$(docker create --ipc=host \
-e HOME=/tmp \
-e RUN_COVERAGE=${RUN_COVERAGE} \
${CI_DEPS_IMAGE} \
sh -lc 'set -e; cd /workspace; npx prisma generate; npm run lint; if [ "$RUN_COVERAGE" = "true" ]; then npm run test:coverage; else npm run test; fi; npm run test:e2e:smoke')
cleanup() {
docker rm -f "$cid" >/dev/null 2>&1 || true
}
trap cleanup EXIT
git archive --format=tar HEAD | docker cp - "$cid":/workspace
set +e
docker start -a "$cid"
status=$?
set -e
docker cp "$cid":/workspace/playwright-report ./playwright-report 2>/dev/null || true
docker cp "$cid":/workspace/test-results ./test-results 2>/dev/null || true
exit $status
'''
}
post {
always {
archiveArtifacts artifacts: 'playwright-report/**,test-results/**', allowEmptyArchive: true
}
}
}
stage('Build Docker Image') {
steps {
sh "docker build --build-arg NEXT_PUBLIC_AMAP_API_KEY=${AMAP_KEY} -t ${APP_NAME}:${BUILD_NUMBER} -t ${APP_NAME}:latest ."
withCredentials([
string(credentialsId: 'amap-api-key', variable: 'AMAP_KEY')
]) {
sh '''
set -e
CACHE_DIR="${BUILDX_CACHE_DIR}"
CACHE_NEW_DIR="${BUILDX_CACHE_DIR}-new"
rm -rf "$CACHE_NEW_DIR"
if docker buildx version >/dev/null 2>&1; then
echo "using docker buildx with local cache"
docker buildx create --name "${APP_NAME}-builder" --use >/dev/null 2>&1 || true
docker buildx use "${APP_NAME}-builder" >/dev/null 2>&1 || true
mkdir -p "$CACHE_DIR"
set +e
docker buildx build \
--load \
--build-arg NEXT_PUBLIC_AMAP_API_KEY="$AMAP_KEY" \
--cache-from type=local,src="$CACHE_DIR" \
--cache-to type=local,dest="$CACHE_NEW_DIR",mode=max \
-t "${APP_NAME}:${BUILD_NUMBER}" \
-t "${APP_NAME}:latest" \
.
bx_status=$?
set -e
if [ $bx_status -eq 0 ]; then
rm -rf "$CACHE_DIR"
mv "$CACHE_NEW_DIR" "$CACHE_DIR"
else
echo "buildx build failed, fallback to classic docker build"
docker build \
--build-arg NEXT_PUBLIC_AMAP_API_KEY="$AMAP_KEY" \
-t "${APP_NAME}:${BUILD_NUMBER}" \
-t "${APP_NAME}:latest" \
.
fi
else
echo "docker buildx unavailable, fallback to classic docker build"
docker build \
--build-arg NEXT_PUBLIC_AMAP_API_KEY="$AMAP_KEY" \
-t "${APP_NAME}:${BUILD_NUMBER}" \
-t "${APP_NAME}:latest" \
.
fi
'''
}
}
}
stage('Deploy') {
steps {
sh """
docker stop ${APP_NAME} || true
docker rm ${APP_NAME} || true
mkdir -p /data/${APP_NAME}
chown 1001:1001 /data/${APP_NAME}
docker run -d \
--name ${APP_NAME} \
--network nginx \
-p 3000:3000 \
-v /data/${APP_NAME}:/app/data \
-e DATABASE_URL=file:/app/data/prod.db \
-e AMAP_API_KEY=${AMAP_KEY} \
-e DEEPSEEK_API_KEY=${DEEPSEEK_KEY} \
--restart unless-stopped \
${APP_NAME}:latest
"""
withCredentials([
string(credentialsId: 'amap-api-key', variable: 'AMAP_KEY'),
string(credentialsId: 'deepseek-api-key', variable: 'DEEPSEEK_KEY')
]) {
sh """
docker stop ${APP_NAME} || true
docker rm ${APP_NAME} || true
mkdir -p /data/${APP_NAME}
chown 1001:1001 /data/${APP_NAME}
docker run -d \
--name ${APP_NAME} \
--network nginx \
-p 3721:3721 \
-v /data/${APP_NAME}:/app/data \
-e DATABASE_URL=file:/app/data/prod.db \
-e AMAP_API_KEY=${AMAP_KEY} \
-e DEEPSEEK_API_KEY=${DEEPSEEK_KEY} \
--restart unless-stopped \
${APP_NAME}:latest
"""
}
}
}
stage('Full E2E (Optional, Non-Blocking)') {
when {
expression { return params.RUN_FULL_E2E }
}
options {
timeout(time: 30, unit: 'MINUTES')
}
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'UNSTABLE') {
sh '''
set -e
rm -rf playwright-report-full test-results-full
cid=$(docker create --ipc=host \
-e HOME=/tmp \
${CI_DEPS_IMAGE} \
sh -lc 'set -e; cd /workspace; npx prisma generate; npm run test:e2e')
cleanup() {
docker rm -f "$cid" >/dev/null 2>&1 || true
}
trap cleanup EXIT
git archive --format=tar HEAD | docker cp - "$cid":/workspace
set +e
docker start -a "$cid"
status=$?
set -e
docker cp "$cid":/workspace/playwright-report ./playwright-report-full 2>/dev/null || true
docker cp "$cid":/workspace/test-results ./test-results-full 2>/dev/null || true
exit $status
'''
}
}
post {
always {
archiveArtifacts artifacts: 'playwright-report-full/**,test-results-full/**', allowEmptyArchive: true
}
}
}
}
+194
View File
@@ -0,0 +1,194 @@
# NoWhatever 产品优化建议
> 基于全量代码审查 + 现有 ROADMAP/BUGFIX 交叉分析,聚焦 ROADMAP 未覆盖的盲区。
> 按产品影响力排序,非技术债务清单。
---
## 一、核心转化漏斗断裂(最高优先级)
### 1. 新用户从"打开"到"第一次滑卡"路径太长
**现状**:首页 → 选模式 → 设置位置 → 选场景/口味/距离/价格 → 创建房间 → 等待加载 → 开始滑
至少 **6 步 + 1 次定位授权**,每一步都是流失点。对选择困难症用户来说,让他们在设置页面做 4 个选择本身就是讽刺。
**建议**:增加「一键开始」快捷入口 —— 用默认定位 + 默认"吃饭"场景 + 3km 直接开房间,3 秒进入滑卡。高级设置保留给回头用户。
### 2. 切换场景清空已选偏好
**现状**`handleSceneChange` 直接重置 cuisines 和 price,用户误触即丢失所有配置,无确认。
**建议**
- 方案 A:缓存每个场景的偏好,切回时恢复
- 方案 B:弹出"切换将清空当前设置,确定?"确认框
### 3. 定位失败引导含糊
**现状**:出现 4 种不同文案(定位中 / 失败 / 被拒绝 / 将使用当前定位),用户不知道该做什么。
**建议**:统一为一个明确的 CTA —— 要么"允许定位"按钮,要么"手动搜索位置"输入框,不要同时展示多个状态。
---
## 二、匹配成功后的闭环缺失
### 4. 缺少"执行"追踪环节
**现状**:匹配到餐厅后,除了导航和电话,没有后续追踪。没有"去了吗?好吃吗?"的闭环。
**建议**:匹配后 2 小时推浏览器通知:"去 XXX 了吗?打个分吧"。分数数据反哺未来推荐质量,同时形成用户习惯。
### 5. 收藏按钮隐藏在登录之后
**现状**:未登录用户在 MatchResult 上看不到收藏按钮,但这恰恰是用户最有收藏冲动的时刻。
**建议**:始终展示心形按钮,点击时触发注册引导("注册即可保存这家店")。转化率优于当前的被动注册引导卡片。
### 6. "Top N 决赛"理解成本高
**现状**:非全员一致时"缩小范围"按钮直接出现,没有解释这是什么、为什么需要。
**建议**:在按钮上方加引导语:"还有 X 家不相上下,再投一轮?"(ROADMAP 已提及但未实现)
---
## 三、盲盒模式参与度瓶颈
### 7. 登录门槛太靠前
**现状**:打开盲盒页面第一件事就是弹登录框,用户连盲盒是什么都没看到就被要求注册。
**建议**:先展示 demo 房间 / 动画演示 / 已有房间的公开预览,让用户理解价值后再引导登录。
### 8. 提交想法缺乏引导和灵感
**现状**:输入框只有 placeholder "写下你的想法...",用户面对空白框不知道写什么。
**建议**:展示 3-5 个随机示例("去城市最高楼看日落"、"挑战一人做一道菜"、"找一家从没去过的店"),点击可直接填入,降低创作门槛。
### 9. AI 计划生成无进度反馈 ✅
**现状**:调用 DeepSeek 生成周末计划可能需要 5-10 秒,只有 phase 变化,用户以为卡死了。
**建议**:加逐步动画:"正在分析你们的想法..." → "正在规划路线..." → "快好了...",降低等待焦虑。
**已实现**
- 新增流式接口 `POST /api/blindbox/plan/stream`,按步骤推送 SSE 进度(分析想法 → 搜索地点 → 规划路线 → 规划周六/周日 → 快好了)。
- 前端优先走流式接口,实时展示服务端进度文案;流式失败时自动回退到普通 `POST /api/blindbox/plan`,并用客户端轮播文案(约 2.8 秒一档)作为后备。
- 计划生成逻辑抽到 `src/lib/blindboxPlanGen.ts`,支持 `onProgress` 回调,供流式与普通接口共用。
### 10. 契约完成率没有激励闭环
**现状**:用户看到完成率数字,但没有任何奖励或惩罚机制。
**建议**
- 连续完成 3 个契约 → 解锁特殊标签 / 头像框
- 连续放鸽子 → 对方可以"惩罚抽签"(指定必须做某个想法)
---
## 四、单人使用体验
### 11. 单人创建房间后陷入永久等待
**现状**`userCount === 1` 时仍然进入"等待其他人完成选择"的 spinner。大量用户其实是单人使用(自己选不了吃什么)。
**建议**:单人模式直接出结果,文案改为"帮你选好了!"而非"全员一致"。这解锁了最大的用户群体。
---
## 五、留存与回访机制空白
### 12. 没有任何回访触发点
**现状**:用户用完一次后没有理由回来。没有推送、没有提醒、没有"每周报告"。
**建议**
- 短期:profile 页加"下次吃什么?"快捷入口 + "上次你们选了 XXX,要不要换一家?"
- 中期:接浏览器 Notification API,工作日下午 5 点推"今晚吃什么?"
### 13. 成就系统只有数字没有情感
**现状**:成就页是统计数据的平铺,没有里程碑、勋章、解锁动画。
**建议**:加入称号体系("选择恐惧症治愈者 Lv.3"、"周末冒险家"),每次解锁新称号播放庆祝动画。数字驱动不了留存,情感才可以。
### 14. 缺少"历史今天"触发
**现状**decision 表有 `createdAt` 字段,但从未用于回忆功能。
**建议**:"一年前的今天,你和 TA 在 XXX 吃了火锅" —— 极强的情感连接 + 回访理由。在首页或成就页展示。
---
## 六、社交裂变短板
### 15. 分享卡片缺少行动号召
**现状**:生成的分享图有餐厅信息 + 二维码,但没有一句吸引新用户的 hook。
**建议**:卡片底部加一行:"选择困难?扫码让 TA 帮你决定 →"
### 16. 邀请页信息不足
**现状**:被邀请者只看到房间 ID 和人数,不知道在选什么、在哪里、选了多久。
**建议**:展示房间场景(吃饭/喝酒)、位置、已滑卡进度,降低加入决策成本。
---
## 七、技术层面影响产品体验的问题
| 问题 | 产品影响 | 建议 |
|------|---------|------|
| 网络断开无任何提示 | 用户滑卡后不知道是否提交成功 | 加"网络已断开"toast + 自动重连机制 |
| 滑卡没有触觉反馈 | 移动端操作感弱,不确定是否成功 | 调用 `navigator.vibrate()` |
| 房间 24h 过期且未登录用户无记录 | 第二天想看"昨天选了什么"看不到 | 引导注册,或用 localStorage 缓存最近一次结果 |
| 图片加载失败静默 | 空白卡片严重影响决策质量 | 加 fallback 占位图 + 重试按钮 |
| 餐厅数量不可预期 | 用户不知道有多少张卡要滑 | 搜索后展示"找到 X 家餐厅",减少焦虑 |
---
## 优先级排序
### 第一梯队:解决"用了但没用起来"
| # | 改动 | 预期效果 | 复杂度 |
|---|------|---------|--------|
| 1 | "一键开始"快速通道 | 新用户转化率翻倍 | 低 |
| 11 | 单人模式自动出结果 | 解锁最大用户群体 | 低 |
| 7 | 盲盒登录后置 | 盲盒模式使用率提升 | 中 |
### 第二梯队:补全核心体验
| # | 改动 | 预期效果 | 复杂度 |
|---|------|---------|--------|
| 5 | 收藏按钮始终可见 | 注册转化率提升 | 低 |
| 6 | Top N 决赛引导语 | 减少二轮投票的困惑 | 低 |
| 8 | 想法灵感提示 | 想法提交量提升 | 低 |
| 9 | AI 生成进度动画 | 减少等待流失 | 低 |
| 3 | 定位引导优化 | 减少首次使用失败率 | 低 |
### 第三梯队:留存与增长
| # | 改动 | 预期效果 | 复杂度 |
|---|------|---------|--------|
| 4 | 执行追踪 + 打分 | 形成使用习惯 | 中 |
| 12 | 回访通知 | 次日/次周留存提升 | 中 |
| 13 | 情感化成就系统 | 长期留存 | 中 |
| 15 | 分享卡片行动号召 | 自然裂变提升 | 低 |
| 14 | 历史今天 | 情感连接 + 回访 | 低 |
### 第四梯队:体验打磨
| # | 改动 | 预期效果 | 复杂度 |
|---|------|---------|--------|
| 2 | 场景切换保留偏好 | 减少操作挫败感 | 低 |
| 10 | 契约激励闭环 | 盲盒活跃度 | 中 |
| 16 | 邀请页信息补全 | 邀请转化率提升 | 低 |
| — | 网络断开提示 | 减少数据丢失投诉 | 中 |
| — | 滑卡触觉反馈 | 移动端操作满足感 | 低 |
---
> 核心原则:**先让更多人用起来,再让用起来的人留下来,最后让留下来的人带新人。**
+272
View File
@@ -0,0 +1,272 @@
# 项目全面审查报告(2026-03-03
## 审查范围与方法
- 范围:`src/``prisma/``Dockerfile``Jenkinsfile``README.md`、测试与构建脚本。
- 方式:静态审查 + 命令基线 + 关键链路手工走读(Panic 模式、房间投票、盲盒计划)。
- 执行过的基线命令:
- `npm run lint`
- `npm test`
- `npx tsc --noEmit`
- `npm run test:coverage`
- `npm run build`(当前环境因无法访问 Google Fonts 未完全通过)
## 结论概览
- 发现高优问题(P0/P1):5 个
- 发现中优问题(P2):6 个
- 主要风险类型:业务功能中断、状态数据污染、鉴权边界不完整、敏感信息暴露、工程质量门禁缺失。
---
## 关键问题(按优先级)
### P0-1 Panic 模式“手动加入房间”输入规则与后端房间 ID 规则不一致(功能性缺陷)【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
- 前端改为 6 位字母数字房间号输入,统一转大写并过滤非法字符;
- 后端 `POST /api/room/[id]/join` 增加房间号格式校验与标准化;
- 补充校验与页面行为测试用例。
- 证据:
- `src/app/panic/page.tsx:173`(要求长度必须为 4
- `src/app/panic/page.tsx:567``maxLength={4}`
- `src/app/panic/page.tsx:566`(仅允许数字)
- `src/lib/roomRepository.ts:21`(实际房间 ID 生成规则为 6 位,且含字母)
- 影响:
- 用户无法通过手输房间号加入大多数房间(尤其是含字母的房间号)。
- 直接影响核心转化路径(邀请后加入房间)。
- 建议:
- 前端加入输入改为 6 位字母数字(与 `ROOM_ID_CHARS` 一致);
- 后端补充房间号格式校验与明确错误提示;
- 增加 E2E 用例覆盖“手输邀请码加入”。
### P0-2 投票接口未校验 `restaurantId` 是否属于房间候选列表(可污染房间状态)【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
- `POST /api/room/[id]/swipe` 增加 `restaurantId` 必须属于当前房间候选列表的强校验;
- 对非法 `restaurantId` 返回 400
- 增加对应 API 测试用例,防止状态污染回归。
- 证据:
- `src/app/api/room/[id]/swipe/route.ts:25`(仅查 index
- `src/app/api/room/[id]/swipe/route.ts:32-42`(即使 `restaurantIndex === -1` 仍可写入 `likes` 并可能设置 `match`
- 影响:
- 客户端可提交伪造 `restaurantId`,导致房间匹配结果异常或不可展示;
- 状态污染会影响所有成员的结果一致性。
- 建议:
- 在进入投票逻辑前强制校验 `restaurantIndex >= 0`,否则返回 400
-`undo/reset` 等相关接口同步增加 ID 合法性校验;
- 增加“非法 restaurantId”单测。
### P1-1 `suggest-item` 接口缺失鉴权(可被匿名滥用,产生外部 API 成本)【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
- `POST /api/blindbox/plan/suggest-item` 增加登录鉴权(无登录态返回 401);
- 新增接口测试,覆盖“未登录拒绝访问”与“登录成功返回推荐”。
- 证据:
- `src/app/api/blindbox/plan/suggest-item/route.ts:6-11`(无 `getAuthUserId` / 无 membership 校验)
- 影响:
- 任意匿名请求可触发 AI + 地图查询,存在成本与滥用风险。
- 建议:
- 至少要求登录态;更稳妥是同时校验房间成员身份(若与房间上下文绑定);
- 配合限流(IP + 用户维度)。
### P1-2 API 错误响应直接回传内部异常细节(信息泄露)【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
- `apiHandler` 对未知异常返回统一文案 `操作失败,请稍后重试`
- 内部异常细节仅保留在服务端日志;
- 更新对应单元测试断言。
- 证据:
- `src/lib/api.ts:63-65`500 响应包含 `ErrorName: message`
- 影响:
- 暴露内部实现信息、库错误细节、潜在环境配置线索;
- 提升攻击者探测效率。
- 建议:
- 客户端统一返回泛化错误文案;
- 详细错误仅记录在服务端日志(可加 requestId 关联)。
### P1-3 CI 配置中存在敏感信息硬编码【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
- `Jenkinsfile` 中地图 key 改为 `credentials('amap-api-key')`
- Webhook 触发 token 改为 `tokenCredentialId` 方式读取凭据;
- 消除源码内硬编码敏感值。
- 证据:
- `Jenkinsfile:6`(地图 key 常量)
- `Jenkinsfile:11`(固定触发 token
- 影响:
- 密钥泄露与流水线被外部触发的风险增加;
- 密钥轮转和环境隔离困难。
- 建议:
- 全部迁移到凭据系统(Jenkins Credentials / Secret Manager);
- 触发 token 改为密文凭据并定期轮换。
---
## 中优问题(P2
### P2-1 SSE 成员校验逻辑未真正启用(前端未传 `userId`)【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
- `useRoomPolling` 改为显式接收 `userId`SSE 连接带上 `?userId=...`
- `room/[id]/page` 传入当前用户 ID
- `GET /api/room/[id]/events` 强制 `userId` 必填并做成员校验(无 userId 返回 401,非成员返回 403);
- 更新 hook 测试覆盖 SSE URL 与缺失 userId 场景。
- 证据:
- `src/app/api/room/[id]/events/route.ts:14-18`(仅在 query 存在 `userId` 时校验)
- `src/hooks/useRoomPolling.ts:32`(实际建立 SSE 连接时未携带 `userId`
- 影响:
- 只要知道房间 ID 即可订阅房间流事件(当前实现下)。
- 建议:
- 改为服务端基于认证态校验,或强制 query/body 校验成员身份;
- 前端补齐身份参数并确保不可伪造(推荐 token/cookie 方案)。
### P2-2 “接受计划”前端逻辑乐观更新过早,且未检查 `res.ok`【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
- `handleAcceptPlan` 改为“后端成功后再更新 `planAccepted/activeContract`”;
- 增加 `res.ok` 检查与失败错误提示;
- 防止后端失败时前端误显示“已接受”。
- 证据:
- `src/hooks/useBlindboxPlan.ts:227-240`(先 `setPlanAccepted(true)`,请求后不判断 `res.ok`
- 影响:
- 后端失败时前端仍显示“已接受”,形成状态不一致。
- 建议:
- 先请求成功再更新 `planAccepted/activeContract`
- 失败回滚并展示错误信息。
### P2-3 TypeScript 基线不通过(测试代码类型漂移)【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
- 修复 `createRequest``NextRequest` 构造参数类型不兼容问题;
- 修复盲盒测试中的 Prisma 事务 mock 签名、SSE 计划 mock 返回结构;
- 补齐路由测试上下文参数与测试数据类型字段(`reason``creatorId``isLoading/error``matchType`);
- `npx tsc --noEmit` 已恢复通过。
- 证据:
- 修复后执行 `npx tsc --noEmit` 已无报错。
- 影响:
- 类型系统对回归的兜底失效;重构时风险上升。
- 建议:
-`tsc --noEmit` 纳入 CI 必跑;
- 优先修复测试目录类型错误,确保类型门禁恢复。
### P2-4 Lint 存在阻塞错误(React hooks 新规则触发)【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
- 修复 `GlobalUserBadge``RestaurantCard``SwipeableCard``PageTransition``useGeolocation` 的 hooks 规则 error
- 修复页面文案中的未转义引号(`react/no-unescaped-entities`);
- 第二轮清理补齐所有剩余 warning(未使用变量、无效 eslint-disable、hooks 依赖、`no-img-element` 策略化处理);
- `npm run lint` 已恢复为 0 error / 0 warning。
- 证据:
- 修复后执行 `npm run lint``0 errors / 0 warnings`
- 代表性问题:
- `src/components/SwipeableCard.tsx:81`render 阶段注册副作用)
- `src/components/GlobalUserBadge.tsx:23`effect 内同步 setState
- `src/components/PageTransition.tsx:14`render 期间 ref 访问)
- `src/hooks/useGeolocation.ts:67`(effect 内直接触发状态写入路径)
- 影响:
- 渲染稳定性与性能风险;后续升级 React/Next 成本上升。
- 建议:
- 先清理 error 级规则,再统一处理 warning;
- 针对 hooks 规则建立最小回归测试。
### P2-5 定时器清理不完整(潜在内存泄漏/卸载后状态写入)【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
- `useBlindboxIdeas` 增加统一 `useEffect` cleanup,卸载时清理所有 `timersRef`
- `useBlindboxDraw` 增加 confetti 停止逻辑(timeout + `requestAnimationFrame` + `confetti.reset`),并在 `handleContinue` 与 unmount 时清理;
- 同步修正盲盒房间页与 `useBlindboxRoom` 的定时器 cleanup 方式,避免 ref 清理时机不稳定。
- 证据:
- `src/hooks/useBlindboxIdeas.ts` 已新增 `timersRef` 统一清理;
- `src/hooks/useBlindboxDraw.ts` 已新增 confetti 与动画回调销毁逻辑。
- 影响:
- 页面切换或快速操作下可能出现卸载后 setState、额外渲染噪音。
- 建议:
- 为上述 hook 增加 `useEffect` cleanup 清理 `timersRef`
- confetti 动画增加销毁标志与取消逻辑。
### P2-6 构建/测试门禁链路不完整【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
- `Jenkinsfile` 新增 `Install Dependencies``Quality Gate` 阶段,执行 `npm ci``npm run lint``npx tsc --noEmit``npm run test:coverage`
- 补齐 `@vitest/coverage-v8` 依赖,恢复 `npm run test:coverage` 可执行;
-`vitest.config.ts` 增加覆盖率阈值(`statements:57 / branches:50 / functions:47 / lines:60`);
- 调整 `blindbox/page``profile/page` 测试异步断言,减少 `act(...)` 噪声。
- 证据:
- `npm run test:coverage` 通过(`54 files / 337 tests`,覆盖率总览满足阈值);
- `npm run lint` 当前 `0 errors``npx tsc --noEmit` 通过。
- 影响:
- 回归问题可能绕过 CI 直接进入部署。
- 建议:
- CI 最少增加:`npm run lint``npx tsc --noEmit``npm test`
- 补齐 coverage 依赖并设定最低阈值;
- 修复 `act(...)` 警告提升测试可信度。
---
## 可重构/可优化项(非阻塞,但收益高)
### R1 计划查询存在 N+1 查询模式【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
- `getPendingPlans``getHistoryPlans` 改为在 `weekendPlan.findMany` 中直接 `select room { name, code }`
- 移除逐条 `findUnique` 查房间的 N+1 查询路径。
- 证据:
- `src/lib/planQueries.ts` 已由“逐条房间查询”改为“单次查询带房间关系”。
### R2 `atomicUpdateRoom` 对 likes 的“全删全建”策略成本较高【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
-`roomRepository` 中新增 `diffRoomLikes`,按差集计算 `toCreate/toDelete`
- `atomicUpdateRoom` 改为 likes 增量更新(`deleteMany + createMany` 仅处理变更项),替代全量重建;
- 补充 `src/lib/roomRepository.test.ts` 验证增量 diff 行为与去重逻辑。
- 证据:
- `src/lib/roomRepository.ts` 已移除 likes 全删全建逻辑,改为差量同步。
### R3 请求参数契约不统一(前端仍大量发送已废弃 `userId`)【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
- 盲盒主链路前端请求已去除冗余 `userId``useBlindboxIdeas``useBlindboxDraw``useBlindboxPlan``useBlindboxRoom``blindbox/page`);
- `useBlindboxRooms` 改为基于登录态启用请求,接口统一使用鉴权 cookie,不再拼接 `userId` query
- 保留房间实时 SSE 的 `userId` 参数(用于已修复的成员校验链路),其余盲盒链路契约已统一。
- 证据:
- `rg` 检索显示盲盒前端链路已无 `?userId=``userId: profile.id` 传参。
### R4 统一 API 调用层(减少重复 fetch + 错误处理分散)【已完成】
- 修复状态:✅ 已完成(2026-03-03
- 修复内容:
-`src/lib/fetcher.ts` 增加统一请求入口 `requestJson``ApiRequestError`,统一 JSON 序列化、响应解析与错误映射;
- 盲盒核心前端链路已迁移到统一调用层(`blindbox/page``useBlindboxIdeas``useBlindboxRoom``useBlindboxDraw``useBlindboxPlan`);
- 降低重复 `fetch + res.ok + res.json` 模板代码,错误处理集中化。
- 证据:
- 以上模块中的请求分支已改为 `requestJson(...)` 调用;
- 相关盲盒 API/UI 回归测试通过,`npx tsc --noEmit` 通过。
---
## 第二轮执行进展(2026-03-03
- 任务 2(清理 lint warning):✅ 已完成
- 结果:`npm run lint` => `0 errors / 0 warnings`
- 任务 3(补 E2E 用例):✅ 已完成(代码已补齐)
- 新增:`playwright.config.ts``e2e/home-navigation.spec.ts``e2e/panic-join.spec.ts``e2e/invite-join.spec.ts`
- CI 门禁:`Jenkinsfile` 已新增 `E2E Gate` 阶段,执行 `test:e2e:install` + `test:e2e`
- 执行结果:已在可联网主机环境完成 Playwright 安装并跑通 `npm run test:e2e`3/3 通过)。
---
## 基线执行结果(本次审查)
- `npm run lint`:失败,`10 errors / 32 warnings`
- `npm test`:通过,`53 files / 329 tests`,但有 `act(...)` 警告。
- `npx tsc --noEmit`:失败(测试相关类型错误若干)。
- `npm run test:coverage`:失败(缺少 `@vitest/coverage-v8`)。
- `npm run build`:本地环境因无法访问 Google Fonts 失败(与当前受限网络环境相关)。
---
## 建议修复顺序(可执行)
1. 先修 P0(房间号规则不一致、swipe 非法 ID 校验)。
2. 再修 P1`suggest-item` 鉴权、API 错误脱敏、CI 密钥治理)。
3. 处理 P2 工程门禁(lint/tsc/CI),恢复“提交即验证”。
4. 最后推进 R 类重构(N+1、增量更新、API client 统一)。
## 交付说明
- 本文档已从“静态审查结论”演进为“修复跟踪文档”,会随每轮修复持续更新状态。
- 现阶段建议继续按“问题分组 + 回归测试同步补齐”的节奏推进。
+13
View File
@@ -52,6 +52,19 @@ npm run dev
Open [http://localhost:3000](http://localhost:3000) in your browser (best viewed on mobile viewport).
## Testing
```bash
npm test
npm run test:coverage
npm run lint
npx tsc --noEmit
npm run test:e2e:install
npm run test:e2e
```
> `test:e2e` 使用 Playwright(固定 `1.51.1` 版本)。首次执行需要网络下载 Playwright CLI 与 Chromium。
## Project Structure
```
+209
View File
@@ -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 nullamap 调用无超时,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 进程内存级发布订阅
+85
View File
@@ -0,0 +1,85 @@
# 测试补充执行文档(2026-03-03
## 目标
- 为近期重构与修复引入的关键路径补齐高价值测试。
- 降低“请求契约回退”“状态错乱”“错误处理分散”三类回归风险。
- 每完成一个测试包:更新本文件状态并创建独立 git commit。
## 执行清单
### T1 `requestJson` 统一请求层单测(`src/lib/fetcher.ts`)【已完成】
- 新增测试文件:`src/lib/fetcher.test.ts`
- 用例清单:
1. 200 JSON 响应:返回解析后的对象。
2. 带 body 请求:自动注入 `Content-Type: application/json` 且正确序列化。
3. 调用方自定义 `Content-Type` 时不被覆盖。
4. 204 响应:返回 `undefined`
5. 非 2xx 且 payload 含 `{ error }`:抛出 `ApiRequestError`message/status/payload 正确。
6. 非 2xx 且纯文本响应:错误消息回退为文本内容。
7. 响应对象无 `text()` 仅有 `json()` 时仍可解析(兼容测试 mock)。
- 通过标准:
1. `npx vitest run src/lib/fetcher.test.ts` 通过。
2. `npx tsc --noEmit` 通过。
- 完成记录:
1. 已新增 `src/lib/fetcher.test.ts`,覆盖 7 个用例,执行通过(2026-03-03)。
### T2 `useBlindboxDraw` 状态机补测(`src/hooks/useBlindboxDraw.ts`)【已完成】
- 新增测试文件:`src/hooks/useBlindboxDraw.test.ts`
- 用例清单:
1. `poolCount=0`:直接报错,不触发请求。
2. 抽取成功:调用 `requestJson`(仅提交 `roomId`),并正确更新 `phase/poolCount/drawnHistory/revealedIdea`
3. 抽取失败:回退到 `pool`,写入错误信息。
- 通过标准:
1. `npx vitest run src/hooks/useBlindboxDraw.test.ts` 通过。
2. `npx tsc --noEmit` 通过。
- 完成记录:
1. 已新增 `src/hooks/useBlindboxDraw.test.ts`,覆盖空池/成功/失败 3 条关键分支(2026-03-03)。
### T3 `useBlindboxRoom` 关键交互补测(`src/hooks/useBlindboxRoom.ts`)【已完成】
- 新增测试文件:`src/hooks/useBlindboxRoom.test.ts`
- 用例清单:
1. 初始化拉取房间成功后,成员判断正确(`isMember=true`)。
2. `handleJoinRoom` 仅提交 `code` 参数,成功后刷新成员状态。
3. `handleLeaveOrDelete` 二次确认流程正确,删除请求不再携带冗余 body。
- 通过标准:
1. `npx vitest run src/hooks/useBlindboxRoom.test.ts` 通过。
2. `npx tsc --noEmit` 通过。
- 完成记录:
1. 已新增 `src/hooks/useBlindboxRoom.test.ts`,覆盖初始化成员识别、仅 code 入参加入、二次确认删除(2026-03-03)。
### T4 `useBlindboxPlan` 核心分支补测(`src/hooks/useBlindboxPlan.ts`)【已完成】
- 新增测试文件:`src/hooks/useBlindboxPlan.test.ts`
- 用例清单:
1. `fetchAcceptedPlan` 正常读取已接受计划并写入 `activeContract`
2. `handleGeneratePlan` 在流式失败时走 fallback API,成功进入 `plan_reveal`
3. `handleAcceptPlan` 成功:设置 `planAccepted`、更新 `activeContract`、触发提示。
4. `handleAcceptPlan` 失败:不应误标记已接受,返回错误提示。
- 通过标准:
1. `npx vitest run src/hooks/useBlindboxPlan.test.ts` 通过。
2. `npx tsc --noEmit` 通过。
- 完成记录:
1. 已新增 `src/hooks/useBlindboxPlan.test.ts`,覆盖 latest、流式失败 fallback、accept 成功/失败四个关键分支(2026-03-03)。
### T5 E2E 关键流程补测(Playwright)【已完成】
- 新增配置与测试:
1. `playwright.config.ts`
2. `e2e/home-navigation.spec.ts`
3. `e2e/panic-join.spec.ts`
4. `e2e/invite-join.spec.ts`
- 用例清单:
1. 首页模式卡片导航:`/` -> `/panic` -> 返回 -> `/blindbox`
2. Panic 手动加入:房间号输入规范化(大写、过滤非法字符、截断 6 位)并成功跳转房间。
3. 邀请页加入:展示房间人数,点击加入后成功跳转房间。
- 通过标准:
1. `npm run test:e2e` 通过。
- 完成记录:
1. 已补齐 Playwright 配置与 3 条 E2E 用例(2026-03-03)。
2. Jenkins 已接入 E2E 门禁(`test:e2e:install` + `test:e2e`)(2026-03-03)。
3. 已安装 `@playwright/test` 与 Chromium 浏览器,并实测 `npm run test:e2e` 通过(3/3)(2026-03-03)。
## 状态追踪
- T1:已完成(2026-03-03
- T2:已完成(2026-03-03
- T3:已完成(2026-03-03
- T4:已完成(2026-03-03
- T5:已完成(代码已补齐,2026-03-03)
+1 -1
View File
@@ -4,7 +4,7 @@ services:
image: no-whatever:latest
container_name: no-whatever
ports:
- "3000:3000"
- "3721:3721"
volumes:
- /data/no-whatever:/app/data
environment:
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
set -e
TEMPLATE="/app/template.db"
DB="/app/data/prod.db"
if [ ! -f "$DB" ]; then
echo "[db] First run — creating database from template"
cp "$TEMPLATE" "$DB"
exec node server.js
fi
echo "[db] Syncing schema..."
# Schema already contains "CREATE TABLE IF NOT EXISTS" from Prisma.
# Only indexes need the IF NOT EXISTS addition.
sqlite3 "$TEMPLATE" ".schema" | \
sed 's/CREATE INDEX /CREATE INDEX IF NOT EXISTS /g' | \
sed 's/CREATE UNIQUE INDEX /CREATE UNIQUE INDEX IF NOT EXISTS /g' | \
sqlite3 "$DB"
# Add missing columns to existing tables
for table in $(sqlite3 "$TEMPLATE" "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_prisma%'"); do
prod_cols=$(sqlite3 "$DB" "PRAGMA table_info('$table')" | cut -d'|' -f2)
sqlite3 "$TEMPLATE" "PRAGMA table_info('$table')" | while IFS='|' read -r _cid name type notnull dflt _pk; do
if ! echo "$prod_cols" | grep -qx "$name"; then
alter="ALTER TABLE \"$table\" ADD COLUMN \"$name\" $type"
[ -n "$dflt" ] && alter="$alter DEFAULT $dflt"
sqlite3 "$DB" "$alter" && echo "[db] + $table.$name" || echo "[db] WARN: failed to add $table.$name"
fi
done
done
echo "[db] Schema sync complete"
exec node server.js
+15
View File
@@ -0,0 +1,15 @@
import { test, expect } from "@playwright/test";
test("@smoke 首页模式卡片可正确导航", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "NoWhatever" })).toBeVisible();
await page.getByRole("button", { name: /极速救场/ }).click();
await expect(page).toHaveURL(/\/panic$/);
await page.getByRole("button", { name: "返回" }).click();
await expect(page).toHaveURL(/\/$/);
await page.getByRole("button", { name: /周末契约/ }).click();
await expect(page).toHaveURL(/\/blindbox$/);
});
+35
View File
@@ -0,0 +1,35 @@
import { test, expect } from "@playwright/test";
test("邀请页可展示房间信息并完成加入", async ({ page }) => {
const joinBodies: Array<{ userId?: string }> = [];
await page.route("**/api/room/ROOM01", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ userCount: 2, scene: "eat" }),
});
});
await page.route("**/api/room/ROOM01/join", async (route) => {
const body = route.request().postDataJSON() as { userId?: string } | null;
joinBodies.push(body ?? {});
await route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
});
await page.goto("/invite/ROOM01");
await expect(page.getByText("ROOM01")).toBeVisible();
await expect(page.getByText("已有 2 人在房间")).toBeVisible();
await page.getByRole("button", { name: "加入房间" }).click();
await expect(page).toHaveURL(/\/room\/ROOM01$/);
expect(joinBodies.length).toBeGreaterThan(0);
expect(joinBodies[0]?.userId).toBeTruthy();
});
+25
View File
@@ -0,0 +1,25 @@
import { test, expect } from "@playwright/test";
test("@smoke Panic 手动加入房间会规范化房间号并发起 join 请求", async ({ page }) => {
const joinBodies: Array<{ userId?: string }> = [];
await page.route("**/api/room/*/join", async (route) => {
const body = route.request().postDataJSON() as { userId?: string } | null;
joinBodies.push(body ?? {});
await route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
});
await page.goto("/panic");
const roomInput = page.getByPlaceholder("输入 6 位房间号");
await roomInput.fill("ab123c4");
await expect(roomInput).toHaveValue("AB123C");
const joinButton = page.getByRole("button", { name: "加入房间" });
await expect(joinButton).toBeEnabled();
await joinButton.click();
await expect(page).toHaveURL(/\/room\/AB123C$/);
expect(joinBodies.length).toBeGreaterThan(0);
expect(joinBodies[0]?.userId).toBeTruthy();
});
+1
View File
@@ -11,6 +11,7 @@ const eslintConfig = defineConfig([
".next/**",
"out/**",
"build/**",
"coverage/**",
"next-env.d.ts",
]),
]);
+3120 -3
View File
File diff suppressed because it is too large Load Diff
+27 -4
View File
@@ -3,17 +3,29 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev --port 3721",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e:install": "playwright install chromium",
"test:e2e:smoke": "playwright test --grep @smoke",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "^6.19.2",
"bcryptjs": "^3.0.3",
"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",
@@ -21,18 +33,29 @@
"qrcode.react": "^4.2.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"swr": "^2.4.0"
"swr": "^2.4.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@playwright/test": "^1.51.1",
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^2.4.6",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jsdom": "^28.1.0",
"msw": "^2.12.10",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.0.18",
"vitest-mock-extended": "^3.1.0"
}
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from "@playwright/test";
const port = process.env.PLAYWRIGHT_PORT ?? "3721";
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`;
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
expect: {
timeout: 5_000,
},
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI
? [["github"], ["html", { open: "never" }]]
: [["list"], ["html", { open: "never" }]],
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
viewport: { width: 390, height: 844 },
locale: "zh-CN",
},
webServer: {
command: "npm run dev",
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "BlindBoxRoom" ADD COLUMN "address" TEXT;
@@ -0,0 +1,69 @@
-- Drop old Room table and recreate with new relational schema
-- Room data is ephemeral (24h TTL), so no data migration needed.
DROP TABLE IF EXISTS "Room";
-- CreateTable
CREATE TABLE "Room" (
"id" TEXT NOT NULL PRIMARY KEY,
"creatorId" TEXT NOT NULL DEFAULT '',
"scene" TEXT NOT NULL DEFAULT 'eat',
"locked" BOOLEAN NOT NULL DEFAULT false,
"match" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE INDEX "Room_expiresAt_idx" ON "Room"("expiresAt");
-- CreateTable
CREATE TABLE "RoomMember" (
"id" TEXT NOT NULL PRIMARY KEY,
"roomId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"kicked" BOOLEAN NOT NULL DEFAULT false,
"joinedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RoomMember_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "RoomMember_roomId_userId_key" ON "RoomMember"("roomId", "userId");
CREATE INDEX "RoomMember_roomId_idx" ON "RoomMember"("roomId");
-- CreateTable
CREATE TABLE "RoomRestaurant" (
"id" TEXT NOT NULL PRIMARY KEY,
"roomId" TEXT NOT NULL,
"restaurantData" TEXT NOT NULL,
CONSTRAINT "RoomRestaurant_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "RoomRestaurant_roomId_idx" ON "RoomRestaurant"("roomId");
-- CreateTable
CREATE TABLE "RoomLike" (
"id" TEXT NOT NULL PRIMARY KEY,
"roomId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"restaurantId" TEXT NOT NULL,
CONSTRAINT "RoomLike_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "RoomLike_roomId_userId_restaurantId_key" ON "RoomLike"("roomId", "userId", "restaurantId");
CREATE INDEX "RoomLike_roomId_idx" ON "RoomLike"("roomId");
-- CreateTable
CREATE TABLE "RoomSwipe" (
"id" TEXT NOT NULL PRIMARY KEY,
"roomId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"count" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "RoomSwipe_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "RoomSwipe_roomId_userId_key" ON "RoomSwipe"("roomId", "userId");
CREATE INDEX "RoomSwipe_roomId_idx" ON "RoomSwipe"("roomId");
+60 -2
View File
@@ -9,13 +9,68 @@ generator client {
model Room {
id String @id
data String
creatorId String
scene String @default("eat")
locked Boolean @default(false)
match String?
createdAt DateTime @default(now())
expiresAt DateTime
members RoomMember[]
restaurants RoomRestaurant[]
likes RoomLike[]
swipes RoomSwipe[]
@@index([expiresAt])
}
model RoomMember {
id String @id @default(cuid())
roomId String
userId String
kicked Boolean @default(false)
joinedAt DateTime @default(now())
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
@@unique([roomId, userId])
@@index([roomId])
}
model RoomRestaurant {
id String @id @default(cuid())
roomId String
restaurantData String
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
@@index([roomId])
}
model RoomLike {
id String @id @default(cuid())
roomId String
userId String
restaurantId String
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
@@unique([roomId, userId, restaurantId])
@@index([roomId])
}
model RoomSwipe {
id String @id @default(cuid())
roomId String
userId String
count Int @default(0)
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
@@unique([roomId, userId])
@@index([roomId])
}
model User {
id String @id @default(cuid())
username String @unique
@@ -67,6 +122,7 @@ model BlindBoxRoom {
name String
creatorId String
city String?
address String?
lat Float?
lng Float?
createdAt DateTime @default(now())
@@ -101,7 +157,9 @@ model BlindBoxIdea {
category String?
timeSlot String?
estimatedMinutes Int?
outdoor Boolean?
costLevel String?
intensity String?
needsBooking Boolean?
searchQuery String?
searchType String?
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

-1
View File
@@ -1,5 +1,4 @@
import sharp from "sharp";
import { mkdirSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest } from "next/server";
export function createRequest(
url: string,
options: {
method?: string;
body?: unknown;
headers?: Record<string, string>;
} = {},
): NextRequest {
const { method = "GET", body, headers = {} } = options;
type NextRequestInit = ConstructorParameters<typeof NextRequest>[1];
const requestHeaders: Record<string, string> = { ...headers };
const init: NextRequestInit = { method, headers: requestHeaders };
if (body !== undefined) {
init.body = JSON.stringify(body);
requestHeaders["content-type"] = "application/json";
}
return new NextRequest(new URL(url, "http://localhost:3721"), init);
}
export function createRouteContext(
params: Record<string, string>,
): { params: Promise<Record<string, string>> } {
return { params: Promise.resolve(params) };
}
export async function parseJsonResponse(response: Response) {
const data = await response.json();
return { status: response.status, data };
}
+125
View File
@@ -0,0 +1,125 @@
import type { Restaurant } from "@/types";
export const TEST_USER = {
id: "user-1",
username: "testuser",
avatar: "🐱",
email: null,
passwordHash: "$2a$10$hashedpassword",
preferences: "{}",
createdAt: new Date("2025-01-01"),
};
export const TEST_USER_2 = {
id: "user-2",
username: "testuser2",
avatar: "🐶",
email: null,
passwordHash: "$2a$10$hashedpassword2",
preferences: "{}",
createdAt: new Date("2025-01-02"),
};
export const TEST_RESTAURANT: Restaurant = {
id: "rest-1",
name: "测试餐厅",
rating: 4.5,
price: "¥80",
distance: "500m",
images: ["https://example.com/img.jpg"],
category: "中餐",
address: "测试地址",
openTime: "09:00-22:00",
tel: "021-12345678",
tag: "川菜",
location: "121.4,31.2",
};
export const TEST_RESTAURANT_2: Restaurant = {
id: "rest-2",
name: "测试餐厅2",
rating: 4.0,
price: "¥60",
distance: "800m",
images: ["https://example.com/img2.jpg"],
category: "日料",
address: "测试地址2",
openTime: "10:00-21:00",
tel: "021-87654321",
tag: "寿司",
location: "121.5,31.3",
};
export const TEST_RESTAURANT_3: Restaurant = {
id: "rest-3",
name: "测试餐厅3",
rating: 3.5,
price: "¥120",
distance: "1200m",
images: ["https://example.com/img3.jpg"],
category: "西餐",
address: "测试地址3",
openTime: "11:00-23:00",
tel: "021-11111111",
tag: "牛排",
location: "121.6,31.4",
};
export const TEST_ROOM_DATA = {
users: [TEST_USER.id, TEST_USER_2.id],
restaurants: [TEST_RESTAURANT, TEST_RESTAURANT_2, TEST_RESTAURANT_3],
likes: {} as Record<string, string[]>,
swipeCounts: {} as Record<string, number>,
match: null as string | null,
creatorId: TEST_USER.id,
locked: false,
kickedUsers: [] as string[],
scene: "eat" as const,
};
export const TEST_BLINDBOX_ROOM = {
id: "bb-room-1",
code: "ABC123",
name: "我们的周末",
creatorId: TEST_USER.id,
city: null,
lat: null,
lng: null,
createdAt: new Date("2025-01-01"),
};
export const TEST_BLINDBOX_IDEA = {
id: "idea-1",
roomId: "bb-room-1",
userId: TEST_USER.id,
content: "去公园野餐",
status: "in_pool",
category: "outdoor",
timeSlot: "morning",
estimatedMinutes: 120,
costLevel: "free",
intensity: "active",
needsBooking: false,
searchQuery: "公园",
searchType: "category",
drawnById: null,
createdAt: new Date("2025-01-01"),
};
export const TEST_WEEKEND_PLAN = {
id: "plan-1",
roomId: "bb-room-1",
userId: TEST_USER.id,
status: "active",
planData: JSON.stringify({
days: [{
date: "周六",
items: [
{ time: "10:00", activity: "去公园", poi: "某公园", address: "xx路", lat: 31.2, lng: 121.4, duration: 120, reason: "天气好" },
],
}],
summary: "愉快的一天",
}),
endTime: null,
createdAt: new Date("2025-01-01"),
};
+13
View File
@@ -0,0 +1,13 @@
import { vi } from "vitest";
import { mockDeep, mockReset } from "vitest-mock-extended";
import type { PrismaClient } from "@prisma/client";
export const prismaMock = mockDeep<PrismaClient>();
vi.mock("@/lib/prisma", () => ({
prisma: prismaMock,
}));
export function resetPrismaMock() {
mockReset(prismaMock);
}
+6
View File
@@ -0,0 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, vi } from "vitest";
afterEach(() => {
vi.restoreAllMocks();
});
+242
View File
@@ -0,0 +1,242 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowLeft,
Trophy,
Zap,
Gift,
ClipboardList,
Target,
TrendingUp,
BarChart3,
} from "lucide-react";
import RestaurantImage from "@/components/RestaurantImage";
import ContractHistoryItem from "@/components/ContractHistoryItem";
import EmptyState from "@/components/EmptyState";
import { Skeleton, RecordItemSkeleton } from "@/components/Skeleton";
import { buildNavUrl } from "@/lib/navigation";
import { useAchievements } from "@/hooks/useAchievements";
import type { Restaurant } from "@/types";
type Tab = "decisions" | "contracts";
function firstImage(r: Restaurant): string {
if (r.images?.length > 0) return r.images[0];
const legacy = (r as unknown as Record<string, unknown>).image;
return typeof legacy === "string" ? legacy : "";
}
export default function AchievementsPage({ initialUserId }: { initialUserId: string }) {
const router = useRouter();
const [tab, setTab] = useState<Tab>("decisions");
const [userId] = useState<string | undefined>(initialUserId || undefined);
const { stats, decisions, contracts, isLoading: loading } = useAchievements(userId);
const statCards = [
{
label: "决策记录",
value: stats.totalDecisions,
icon: Target,
color: "text-amber-400",
bg: "bg-amber-600/15",
},
{
label: "契约完成",
value: stats.completedContracts,
icon: Trophy,
color: "text-emerald-400",
bg: "bg-emerald-600/15",
},
{
label: "完成率",
value: stats.totalContracts > 0 ? `${stats.completionRate}%` : "—",
icon: TrendingUp,
color: "text-purple-400",
bg: "bg-purple-600/15",
},
];
const tabs: { id: Tab; label: string; icon: typeof Zap }[] = [
{ id: "decisions", label: "极速救场", icon: Zap },
{ id: "contracts", label: "周末契约", icon: Gift },
];
return (
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-6 overflow-y-auto scrollbar-none">
{/* Ambient glow */}
<div className="pointer-events-none fixed left-1/2 top-0 -translate-x-1/2 -translate-y-1/3 h-[320px] w-[320px] rounded-full bg-purple-500/8 blur-3xl" />
{/* Header */}
<div className="flex w-full max-w-sm items-center gap-3">
<button
onClick={() => router.back()}
aria-label="返回"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface ring-1 ring-border transition-colors active:bg-elevated"
>
<ArrowLeft size={16} className="text-muted" />
</button>
<div className="flex items-center gap-2">
<Trophy size={18} className="text-amber-400" />
<h1 className="text-base font-bold text-heading"></h1>
</div>
</div>
{/* Stats */}
<motion.div
className="mt-6 grid w-full max-w-sm grid-cols-3 gap-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
{statCards.map((s) => (
<div
key={s.label}
className="flex flex-col items-center gap-1.5 rounded-xl bg-surface p-3 ring-1 ring-border"
>
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${s.bg}`}>
<s.icon size={16} className={s.color} />
</div>
{loading ? (
<Skeleton className="h-5 w-10" />
) : (
<p className="text-lg font-black text-heading">{s.value}</p>
)}
<p className="text-[10px] text-muted">{s.label}</p>
</div>
))}
</motion.div>
{/* Tab switcher */}
<motion.div
className="mt-6 flex w-full max-w-sm rounded-xl bg-surface p-1 ring-1 ring-border"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`relative flex flex-1 items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-colors ${
tab === t.id ? "text-heading" : "text-muted"
}`}
>
{tab === t.id && (
<motion.div
layoutId="activeTab"
className="absolute inset-0 rounded-lg bg-elevated ring-1 ring-border"
transition={{ type: "spring", damping: 25, stiffness: 350 }}
/>
)}
<t.icon size={13} className="relative z-10" />
<span className="relative z-10">{t.label}</span>
</button>
))}
</motion.div>
{/* Content */}
<div className="mt-4 w-full max-w-sm">
<AnimatePresence mode="wait">
{tab === "decisions" && (
<motion.div
key="decisions"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.2 }}
className="flex flex-col gap-2"
>
{loading ? (
<>
<RecordItemSkeleton />
<RecordItemSkeleton />
<RecordItemSkeleton />
</>
) : decisions.length === 0 ? (
<EmptyState
icon={ClipboardList}
image="/empty-no-record.png"
title="还没有决策记录"
subtitle="使用极速救场后会在这里记录"
color="amber"
/>
) : (
decisions.map((d) => (
<a
key={d.id}
href={buildNavUrl(d.restaurantData)}
target="_blank"
rel="noopener noreferrer"
className="flex gap-3 rounded-xl bg-elevated p-2.5 transition-colors active:bg-subtle"
>
{firstImage(d.restaurantData) && (
<RestaurantImage
src={firstImage(d.restaurantData)}
alt={d.restaurantName}
className="h-12 w-12 shrink-0 rounded-lg object-cover"
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center">
<p className="truncate text-sm font-semibold text-heading">
{d.restaurantName}
</p>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted">
<span>
{d.matchType === "unanimous" ? "全员一致" : "最佳匹配"}
</span>
<span>{d.participants} </span>
<span>
{new Date(d.createdAt).toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
})}
</span>
</div>
</div>
</a>
))
)}
</motion.div>
)}
{tab === "contracts" && (
<motion.div
key="contracts"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
className="flex flex-col gap-2"
>
{loading ? (
<>
<RecordItemSkeleton />
<RecordItemSkeleton />
<RecordItemSkeleton />
</>
) : contracts.length === 0 ? (
<EmptyState
icon={BarChart3}
image="/empty-no-room.png"
title="还没有契约记录"
subtitle="完成或过期的契约会在这里显示"
color="purple"
/>
) : (
contracts.map((c) => (
<ContractHistoryItem key={c.id} record={c} />
))
)}
</motion.div>
)}
</AnimatePresence>
</div>
<div className="h-8 shrink-0" />
</div>
);
}
+16 -270
View File
@@ -1,274 +1,20 @@
"use client";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { verifyToken } from "@/lib/auth";
import AchievementsClient from "./AchievementsClient";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowLeft,
Trophy,
Zap,
Gift,
ClipboardList,
Target,
TrendingUp,
BarChart3,
} from "lucide-react";
import { isRegistered, getCachedProfile } from "@/lib/userId";
import RestaurantImage from "@/components/RestaurantImage";
import ContractHistoryItem from "@/components/ContractHistoryItem";
import EmptyState from "@/components/EmptyState";
import { Skeleton, RecordItemSkeleton } from "@/components/Skeleton";
import { buildNavUrl } from "@/lib/navigation";
import type { DecisionRecord, ContractRecord, Restaurant } from "@/types";
export default async function AchievementsPage() {
const cookieStore = await cookies();
const token = cookieStore.get("nw_token")?.value;
type Tab = "decisions" | "contracts";
if (!token) redirect("/");
interface Stats {
totalDecisions: number;
totalContracts: number;
completedContracts: number;
completionRate: number;
}
function firstImage(r: Restaurant): string {
if (r.images?.length > 0) return r.images[0];
const legacy = (r as unknown as Record<string, unknown>).image;
return typeof legacy === "string" ? legacy : "";
}
export default function AchievementsPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>("decisions");
const [stats, setStats] = useState<Stats>({
totalDecisions: 0,
totalContracts: 0,
completedContracts: 0,
completionRate: 0,
});
const [decisions, setDecisions] = useState<DecisionRecord[]>([]);
const [contracts, setContracts] = useState<ContractRecord[]>([]);
useEffect(() => {
if (!isRegistered()) {
router.replace("/");
return;
}
const p = getCachedProfile();
if (!p) return;
(async () => {
try {
const res = await fetch(`/api/user/achievements?userId=${p.id}`);
if (!res.ok) return;
const data = await res.json();
setStats(data.stats);
setDecisions(data.decisions);
setContracts(data.contracts);
} catch { /* ignore */ }
finally { setLoading(false); }
})();
}, [router]);
const statCards = [
{
label: "决策记录",
value: stats.totalDecisions,
icon: Target,
color: "text-amber-400",
bg: "bg-amber-600/15",
},
{
label: "契约完成",
value: stats.completedContracts,
icon: Trophy,
color: "text-emerald-400",
bg: "bg-emerald-600/15",
},
{
label: "完成率",
value: stats.totalContracts > 0 ? `${stats.completionRate}%` : "—",
icon: TrendingUp,
color: "text-purple-400",
bg: "bg-purple-600/15",
},
];
const tabs: { id: Tab; label: string; icon: typeof Zap }[] = [
{ id: "decisions", label: "极速救场", icon: Zap },
{ id: "contracts", label: "周末契约", icon: Gift },
];
return (
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-6 overflow-y-auto scrollbar-none">
{/* Ambient glow */}
<div className="pointer-events-none fixed left-1/2 top-0 -translate-x-1/2 -translate-y-1/3 h-[320px] w-[320px] rounded-full bg-purple-500/8 blur-3xl" />
{/* Header */}
<div className="flex w-full max-w-sm items-center gap-3">
<button
onClick={() => router.back()}
aria-label="返回"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface ring-1 ring-border transition-colors active:bg-elevated"
>
<ArrowLeft size={16} className="text-muted" />
</button>
<div className="flex items-center gap-2">
<Trophy size={18} className="text-amber-400" />
<h1 className="text-base font-bold text-heading"></h1>
</div>
</div>
{/* Stats */}
<motion.div
className="mt-6 grid w-full max-w-sm grid-cols-3 gap-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
{statCards.map((s) => (
<div
key={s.label}
className="flex flex-col items-center gap-1.5 rounded-xl bg-surface p-3 ring-1 ring-border"
>
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${s.bg}`}>
<s.icon size={16} className={s.color} />
</div>
{loading ? (
<Skeleton className="h-5 w-10" />
) : (
<p className="text-lg font-black text-heading">{s.value}</p>
)}
<p className="text-[10px] text-muted">{s.label}</p>
</div>
))}
</motion.div>
{/* Tab switcher */}
<motion.div
className="mt-6 flex w-full max-w-sm rounded-xl bg-surface p-1 ring-1 ring-border"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`relative flex flex-1 items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-colors ${
tab === t.id ? "text-heading" : "text-muted"
}`}
>
{tab === t.id && (
<motion.div
layoutId="activeTab"
className="absolute inset-0 rounded-lg bg-elevated ring-1 ring-border"
transition={{ type: "spring", damping: 25, stiffness: 350 }}
/>
)}
<t.icon size={13} className="relative z-10" />
<span className="relative z-10">{t.label}</span>
</button>
))}
</motion.div>
{/* Content */}
<div className="mt-4 w-full max-w-sm">
<AnimatePresence mode="wait">
{tab === "decisions" && (
<motion.div
key="decisions"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.2 }}
className="flex flex-col gap-2"
>
{loading ? (
<>
<RecordItemSkeleton />
<RecordItemSkeleton />
<RecordItemSkeleton />
</>
) : decisions.length === 0 ? (
<EmptyState
icon={ClipboardList}
title="还没有决策记录"
subtitle="使用极速救场后会在这里记录"
color="amber"
/>
) : (
decisions.map((d) => (
<a
key={d.id}
href={buildNavUrl(d.restaurantData)}
target="_blank"
rel="noopener noreferrer"
className="flex gap-3 rounded-xl bg-elevated p-2.5 transition-colors active:bg-subtle"
>
{firstImage(d.restaurantData) && (
<RestaurantImage
src={firstImage(d.restaurantData)}
alt={d.restaurantName}
className="h-12 w-12 shrink-0 rounded-lg object-cover"
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center">
<p className="truncate text-sm font-semibold text-heading">
{d.restaurantName}
</p>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted">
<span>
{d.matchType === "unanimous" ? "全员一致" : "最佳匹配"}
</span>
<span>{d.participants} </span>
<span>
{new Date(d.createdAt).toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
})}
</span>
</div>
</div>
</a>
))
)}
</motion.div>
)}
{tab === "contracts" && (
<motion.div
key="contracts"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
className="flex flex-col gap-2"
>
{loading ? (
<>
<RecordItemSkeleton />
<RecordItemSkeleton />
<RecordItemSkeleton />
</>
) : contracts.length === 0 ? (
<EmptyState
icon={BarChart3}
title="还没有契约记录"
subtitle="完成或过期的契约会在这里显示"
color="purple"
/>
) : (
contracts.map((c) => (
<ContractHistoryItem key={c.id} record={c} />
))
)}
</motion.div>
)}
</AnimatePresence>
</div>
<div className="h-8 shrink-0" />
</div>
);
let userId: string;
try {
userId = await verifyToken(token);
} catch {
redirect("/");
}
return <AchievementsClient initialUserId={userId} />;
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER } from "@/__tests__/helpers/fixtures";
vi.mock("bcryptjs", () => ({
default: {
compare: vi.fn(),
},
}));
import bcrypt from "bcryptjs";
import { POST } from "./route";
const mockCompare = vi.mocked(bcrypt.compare);
beforeEach(() => {
resetPrismaMock();
mockCompare.mockReset();
});
describe("POST /api/auth/login", () => {
it("logs in successfully with correct credentials", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
mockCompare.mockResolvedValue(true as never);
const req = createRequest("/api/auth/login", {
method: "POST",
body: { username: "testuser", password: "password123" },
});
const res = await POST(req, { params: Promise.resolve({}) });
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.id).toBe(TEST_USER.id);
expect(data.username).toBe("testuser");
expect(data.avatar).toBe("🐱");
});
it("returns 401 when user not found", async () => {
prismaMock.user.findUnique.mockResolvedValue(null as never);
const req = createRequest("/api/auth/login", {
method: "POST",
body: { username: "nonexistent", password: "password123" },
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(401);
});
it("returns 401 when password is wrong", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
mockCompare.mockResolvedValue(false as never);
const req = createRequest("/api/auth/login", {
method: "POST",
body: { username: "testuser", password: "wrongpassword" },
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(401);
});
it("returns 400 when fields are missing", async () => {
const req = createRequest("/api/auth/login", {
method: "POST",
body: {},
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(400);
});
});
+5 -1
View File
@@ -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);
});
+7
View File
@@ -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);
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER } from "@/__tests__/helpers/fixtures";
vi.mock("bcryptjs", () => ({
default: { hash: vi.fn().mockResolvedValue("$2a$10$hashed") },
}));
import { POST } from "./route";
beforeEach(() => {
resetPrismaMock();
});
describe("POST /api/auth/register", () => {
it("registers a new user successfully", async () => {
prismaMock.user.create.mockResolvedValue({
...TEST_USER,
id: "new-user",
username: "newuser",
avatar: "🐱",
} as never);
const req = createRequest("/api/auth/register", {
method: "POST",
body: { username: "newuser", password: "password123" },
});
const res = await POST(req, { params: Promise.resolve({}) });
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.username).toBe("newuser");
expect(data.id).toBe("new-user");
expect(data.avatar).toBe("🐱");
});
it("uses custom avatar if provided", async () => {
prismaMock.user.create.mockResolvedValue({
...TEST_USER,
avatar: "🦊",
} as never);
const req = createRequest("/api/auth/register", {
method: "POST",
body: { username: "newuser", password: "password123", avatar: "🦊" },
});
const res = await POST(req, { params: Promise.resolve({}) });
const { data } = await parseJsonResponse(res);
expect(data.avatar).toBe("🦊");
});
it("returns 400 when username is missing", async () => {
const req = createRequest("/api/auth/register", {
method: "POST",
body: { password: "password123" },
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(400);
});
it("returns 400 when password is missing", async () => {
const req = createRequest("/api/auth/register", {
method: "POST",
body: { username: "testuser" },
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(400);
});
it("returns 400 when username too short", async () => {
const req = createRequest("/api/auth/register", {
method: "POST",
body: { username: "a", password: "password123" },
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(400);
});
it("returns 400 when password too short", async () => {
const req = createRequest("/api/auth/register", {
method: "POST",
body: { username: "testuser", password: "12345" },
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(400);
});
it("returns 409 when username already exists", async () => {
const { Prisma } = await import("@prisma/client");
prismaMock.user.create.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
}),
);
const req = createRequest("/api/auth/register", {
method: "POST",
body: { username: "existing", password: "password123" },
});
const res = await POST(req, { params: Promise.resolve({}) });
expect(res.status).toBe(409);
});
});
+5 -1
View File
@@ -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);
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
}));
import { POST } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
});
describe("POST /api/blindbox/draw", () => {
it("draws a random idea", async () => {
prismaMock.$transaction.mockImplementation((async (fn: unknown) => {
const tx = {
blindBoxIdea: {
findMany: vi.fn().mockResolvedValue([{ id: "idea-1" }]),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
findUnique: vi.fn().mockResolvedValue({
id: "idea-1",
content: "去公园",
createdAt: new Date(),
user: { id: "user-2", username: "submitter", avatar: "🐶" },
drawnBy: { id: "user-1", username: "drawer", avatar: "🐱" },
}),
},
};
return (fn as (txArg: typeof tx) => Promise<unknown>)(tx);
}) as never);
const req = createRequest("/api/blindbox/draw", {
method: "POST",
body: { roomId: "bb-room-1" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.id).toBe("idea-1");
expect(data.content).toBe("去公园");
expect(data.submitter).toBeDefined();
expect(data.drawnBy).toBeDefined();
});
it("returns 404 when pool is empty", async () => {
prismaMock.$transaction.mockImplementation((async (fn: unknown) => {
const tx = {
blindBoxIdea: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn(),
findUnique: vi.fn(),
},
};
return (fn as (txArg: typeof tx) => Promise<unknown>)(tx);
}) as never);
const req = createRequest("/api/blindbox/draw", {
method: "POST",
body: { roomId: "bb-room-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(404);
});
it("returns 409 on race condition (count=0)", async () => {
prismaMock.$transaction.mockImplementation((async (fn: unknown) => {
const tx = {
blindBoxIdea: {
findMany: vi.fn().mockResolvedValue([{ id: "idea-1" }]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
findUnique: vi.fn(),
},
};
return (fn as (txArg: typeof tx) => Promise<unknown>)(tx);
}) as never);
const req = createRequest("/api/blindbox/draw", {
method: "POST",
body: { roomId: "bb-room-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(409);
});
it("returns 400 when roomId is missing", async () => {
const req = createRequest("/api/blindbox/draw", {
method: "POST",
body: {},
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
});
+4 -3
View File
@@ -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);
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api";
import { refinePlan } from "@/lib/ai";
import { getAuthUserId } from "@/lib/auth";
export const POST = apiHandler(async (req) => {
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);
const newDays = await refinePlan(days, instruction);
if (!newDays) throw new ApiError("AI 调整失败,请重试", 500);
return NextResponse.json({ days: newDays });
});
+192
View File
@@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_WEEKEND_PLAN } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/blindboxPlanGen", () => ({
runPlanGeneration: vi.fn().mockResolvedValue({
id: "plan-1",
days: [{ date: "周六", items: [] }],
createdAt: new Date(),
}),
}));
import { POST, PATCH, GET } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
});
describe("POST /api/blindbox/plan", () => {
it("generates a weekend plan", async () => {
const req = createRequest("/api/blindbox/plan", {
method: "POST",
body: {
roomId: "bb-room-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
},
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.id).toBe("plan-1");
});
it("returns 400 for invalid available time", async () => {
const req = createRequest("/api/blindbox/plan", {
method: "POST",
body: {
roomId: "bb-room-1",
availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 },
},
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 400 when roomId is missing", async () => {
const req = createRequest("/api/blindbox/plan", {
method: "POST",
body: {
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
},
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
});
describe("PATCH /api/blindbox/plan", () => {
it("accepts a plan", async () => {
prismaMock.weekendPlan.findUnique.mockResolvedValue({
...TEST_WEEKEND_PLAN,
status: "active",
} as never);
prismaMock.weekendPlan.update.mockResolvedValue({} as never);
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", action: "accept" },
});
const res = await PATCH(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.ok).toBe(true);
});
it("completes an accepted plan", async () => {
prismaMock.weekendPlan.findUnique.mockResolvedValue({
...TEST_WEEKEND_PLAN,
status: "accepted",
} as never);
prismaMock.weekendPlan.update.mockResolvedValue({} as never);
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", action: "complete" },
});
const res = await PATCH(req, mockCtx);
expect(res.status).toBe(200);
});
it("expires an accepted plan", async () => {
prismaMock.weekendPlan.findUnique.mockResolvedValue({
...TEST_WEEKEND_PLAN,
status: "accepted",
} as never);
prismaMock.weekendPlan.update.mockResolvedValue({} as never);
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", action: "expire" },
});
const res = await PATCH(req, mockCtx);
expect(res.status).toBe(200);
});
it("returns 400 when accepting non-active plan", async () => {
prismaMock.weekendPlan.findUnique.mockResolvedValue({
...TEST_WEEKEND_PLAN,
status: "accepted",
} as never);
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", action: "accept" },
});
const res = await PATCH(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 403 when not plan owner", async () => {
prismaMock.weekendPlan.findUnique.mockResolvedValue({
...TEST_WEEKEND_PLAN,
userId: "other-user",
} as never);
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", action: "accept" },
});
const res = await PATCH(req, mockCtx);
expect(res.status).toBe(403);
});
it("returns 400 for invalid action", async () => {
prismaMock.weekendPlan.findUnique.mockResolvedValue(TEST_WEEKEND_PLAN as never);
const req = createRequest("/api/blindbox/plan", {
method: "PATCH",
body: { planId: "plan-1", action: "invalid" },
});
const res = await PATCH(req, mockCtx);
expect(res.status).toBe(400);
});
});
describe("GET /api/blindbox/plan", () => {
it("returns latest accepted plan", async () => {
prismaMock.weekendPlan.findFirst.mockResolvedValue({
id: "plan-1",
planData: JSON.stringify({ days: [{ date: "周六", items: [] }] }),
endTime: null,
createdAt: new Date(),
} as never);
const req = createRequest("/api/blindbox/plan?mode=latest&roomId=bb-room-1");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.plan).toBeDefined();
expect(data.plan.id).toBe("plan-1");
});
it("returns null when no plan found", async () => {
prismaMock.weekendPlan.findFirst.mockResolvedValue(null as never);
const req = createRequest("/api/blindbox/plan?mode=latest&roomId=bb-room-1");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.plan).toBeNull();
});
it("returns 400 for invalid mode", async () => {
const req = createRequest("/api/blindbox/plan?mode=invalid");
const res = await GET(req, mockCtx);
expect(res.status).toBe(400);
});
});
+19 -427
View File
@@ -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 { requireAmapApiKey } from "@/lib/amap";
import { generateSchedule, type ScheduleContext } from "@/lib/ai";
import { apiHandler, ApiError } from "@/lib/api";
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
import { getAuthUserId } from "@/lib/auth";
import { handlePlanUpdate } from "@/lib/planActions";
import { getLatestPlan, getPendingPlans, getHistoryPlans } from "@/lib/planQueries";
interface AvailableTime {
date: string;
@@ -11,133 +12,10 @@ interface AvailableTime {
endHour: number;
}
interface TaggedIdea {
id: string;
content: string;
category: string;
timeSlot: string;
estimatedMinutes: number;
searchQuery: string;
searchType: string;
}
const SLOT_CATEGORY_MAP: Record<string, string[]> = {
morning: ["outdoor", "sports", "culture"],
lunch: ["dining"],
afternoon: ["entertainment", "shopping", "relaxation", "outdoor", "culture"],
dinner: ["dining"],
evening: ["entertainment", "relaxation"],
};
function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): TaggedIdea[] {
const byCategory = new Map<string, TaggedIdea[]>();
for (const idea of ideas) {
const list = byCategory.get(idea.category) || [];
list.push(idea);
byCategory.set(idea.category, list);
}
const slots: string[] = [];
if (availableHours >= 10) {
slots.push("morning", "lunch", "afternoon", "dinner", "evening");
} else if (availableHours >= 7) {
slots.push("morning", "lunch", "afternoon", "evening");
} else if (availableHours >= 5) {
slots.push("lunch", "afternoon", "evening");
} else {
slots.push("afternoon", "evening");
}
const selected: TaggedIdea[] = [];
const usedIds = new Set<string>();
for (const slot of slots) {
const preferredCategories = SLOT_CATEGORY_MAP[slot] || [];
let picked: TaggedIdea | null = null;
for (const cat of preferredCategories) {
const pool = (byCategory.get(cat) || []).filter((i) => !usedIds.has(i.id));
if (pool.length > 0) {
picked = pool[Math.floor(Math.random() * pool.length)];
break;
}
}
if (!picked) {
const remaining = ideas.filter((i) => !usedIds.has(i.id));
if (remaining.length > 0) {
picked = remaining[Math.floor(Math.random() * remaining.length)];
}
}
if (picked) {
selected.push(picked);
usedIds.add(picked.id);
}
}
return selected;
}
async function searchPois(
query: string,
searchType: string,
anchorLat: number,
anchorLng: number,
): Promise<{ name: string; address: string; lat: number; lng: number; rating?: number }[]> {
const apiKey = requireAmapApiKey();
if (searchType === "category") {
const url = new URL("https://restapi.amap.com/v5/place/around");
url.searchParams.set("key", apiKey);
url.searchParams.set("location", `${anchorLng},${anchorLat}`);
url.searchParams.set("keywords", query);
url.searchParams.set("radius", "5000");
url.searchParams.set("show_fields", "business");
url.searchParams.set("page_size", "8");
const res = await fetch(url.toString());
const data = await res.json();
if (data.status !== "1" || !data.pois?.length) return [];
return mapPois(data.pois);
}
// Text/brand search — bias results to the room's location
const url = new URL("https://restapi.amap.com/v5/place/text");
url.searchParams.set("key", apiKey);
url.searchParams.set("keywords", query);
url.searchParams.set("location", `${anchorLng},${anchorLat}`);
url.searchParams.set("show_fields", "business");
url.searchParams.set("page_size", "8");
const res = await fetch(url.toString());
const data = await res.json();
if (data.status !== "1" || !data.pois?.length) return [];
return mapPois(data.pois);
}
function mapPois(
pois: { name: string; address?: string; location?: string; business?: { rating?: string } }[],
) {
return pois
.filter((p) => p.location)
.map((p) => {
const [lng, lat] = (p.location ?? "0,0").split(",").map(Number);
const ratingStr = p.business?.rating;
return {
name: p.name,
address: p.address || "",
lat,
lng,
rating: ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || undefined : undefined,
};
});
}
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);
@@ -152,327 +30,41 @@ export const POST = apiHandler(async (req) => {
throw new ApiError("请选择有效的可用时间");
}
const room = await prisma.blindBoxRoom.findUnique({ where: { id: roomId } });
if (!room) throw new ApiError("房间不存在", 404);
if (!room.lat || !room.lng) {
throw new ApiError("请先设置房间位置", 400);
}
const allIdeas = await prisma.blindBoxIdea.findMany({
where: { roomId, status: "in_pool", category: { not: null } },
select: {
id: true,
content: true,
category: true,
timeSlot: true,
estimatedMinutes: true,
searchQuery: true,
searchType: true,
},
});
const taggedIdeas: TaggedIdea[] = allIdeas.filter(
(i): i is TaggedIdea =>
!!i.category && !!i.timeSlot && !!i.searchQuery && !!i.searchType &&
typeof i.estimatedMinutes === "number",
);
if (taggedIdeas.length < 2) {
throw new ApiError("盒子里至少需要 2 个已标记的想法才能生成计划", 400);
}
// Split into day configs — "整个周末" generates two separate days
const dayConfigs: AvailableTime[] =
at.date === "整个周末"
? [
{ date: "周六", startHour: at.startHour, endHour: at.endHour },
{ date: "周日", startHour: at.startHour, endHour: at.endHour },
]
: [at];
// Select ideas per day — skip extra days when ideas run out
const dayIdeas: TaggedIdea[][] = [];
const usedIds = new Set<string>();
for (const dayConfig of dayConfigs) {
const remaining = taggedIdeas.filter((i) => !usedIds.has(i.id));
if (remaining.length < 2) break;
const selected = selectIdeasForSlots(remaining, dayConfig.endHour - dayConfig.startHour);
for (const idea of selected) usedIds.add(idea.id);
dayIdeas.push(selected);
}
// Trim to actual days generated (may be fewer than requested for "整个周末")
const actualDayConfigs = dayConfigs.slice(0, dayIdeas.length);
const allSelected = dayIdeas.flat();
if (allSelected.length === 0) {
throw new ApiError("无法从想法池中选出合适的活动", 400);
}
// Deduplicate search queries across all days
const uniqueByQuery = new Map<string, TaggedIdea>();
for (const idea of allSelected) {
if (!uniqueByQuery.has(idea.searchQuery)) uniqueByQuery.set(idea.searchQuery, idea);
}
// Phase 1: search brand/place type queries in parallel
const brandPlaceQueries = [...uniqueByQuery.values()].filter((i) => i.searchType !== "category");
const searchResults = await Promise.all(
brandPlaceQueries.map(async (idea) => {
try {
const pois = await searchPois(idea.searchQuery, idea.searchType, room.lat!, room.lng!);
return { query: idea.searchQuery, pois };
} catch {
return { query: idea.searchQuery, pois: [] };
}
}),
);
const candidates: ScheduleContext["candidates"] = {};
for (const result of searchResults) {
candidates[result.query] = result.pois;
}
// Phase 2: category-type queries anchored to centroid of found POIs
const catQueries = [...uniqueByQuery.values()].filter((i) => i.searchType === "category");
if (catQueries.length > 0) {
const allPois = Object.values(candidates).flat();
let anchorLat = room.lat;
let anchorLng = room.lng;
if (allPois.length > 0) {
anchorLat = allPois.reduce((s, p) => s + p.lat, 0) / allPois.length;
anchorLng = allPois.reduce((s, p) => s + p.lng, 0) / allPois.length;
}
const catResults = await Promise.all(
catQueries.map(async (idea) => {
try {
const pois = await searchPois(idea.searchQuery, idea.searchType, anchorLat, anchorLng);
return { query: idea.searchQuery, pois };
} catch {
return { query: idea.searchQuery, pois: [] };
}
}),
);
for (const result of catResults) {
candidates[result.query] = result.pois;
}
}
// Generate schedule for each day (parallel AI calls)
const schedules = await Promise.all(
actualDayConfigs.map((dayConfig, idx) => {
const ideas = dayIdeas[idx];
const ctx: ScheduleContext = {
ideas: ideas.map((i) => ({
content: i.content,
category: i.category,
timeSlot: i.timeSlot,
estimatedMinutes: i.estimatedMinutes,
searchQuery: i.searchQuery,
searchType: i.searchType,
})),
candidates,
userLocation: { lat: room.lat!, lng: room.lng! },
availableTime: dayConfig,
};
return generateSchedule(ctx);
}),
);
const days = schedules
.map((schedule, idx) =>
schedule
? { date: actualDayConfigs[idx].date, items: schedule.items, summary: schedule.summary }
: null,
)
.filter((d) => d !== null);
if (days.length === 0) {
throw new ApiError("AI 规划失败,请稍后重试", 500);
}
const plan = await prisma.weekendPlan.create({
data: {
roomId,
userId,
planData: JSON.stringify({
days,
selectedIdeaIds: allSelected.map((i) => i.id),
}),
},
});
const result = await runPlanGeneration(roomId, userId, at);
return NextResponse.json({
id: plan.id,
days,
createdAt: plan.createdAt,
id: result.id,
days: result.days,
createdAt: result.createdAt,
});
});
/**
* Map "周六"/"周日" to the next occurrence of that weekday from a reference date.
* Returns a Date at 00:00 of that day.
*/
function nextWeekday(dayLabel: string, from: Date): Date {
const targetDow = dayLabel === "周日" ? 0 : 6; // Sunday=0, Saturday=6
const d = new Date(from);
d.setHours(0, 0, 0, 0);
const diff = (targetDow - d.getDay() + 7) % 7;
d.setDate(d.getDate() + (diff === 0 ? 0 : diff));
return d;
}
function computeEndTime(planData: string, now: Date): Date | null {
try {
const parsed = JSON.parse(planData);
const days = parsed.days as { date: string; items: { time: string; duration: number }[] }[];
if (!days?.length) return null;
const lastDay = days[days.length - 1];
const lastItem = lastDay.items[lastDay.items.length - 1];
if (!lastItem) return null;
const base = nextWeekday(lastDay.date, now);
const [h, m] = lastItem.time.split(":").map(Number);
base.setHours(h, m, 0, 0);
base.setMinutes(base.getMinutes() + (lastItem.duration || 60));
// If computed end time is in the past, it's for next week
if (base.getTime() < now.getTime()) {
base.setDate(base.getDate() + 7);
}
return base;
} catch {
return null;
}
}
export const PATCH = apiHandler(async (req) => {
const { planId, userId, action } = await req.json();
requireUserId(userId);
const userId = await getAuthUserId(req);
const { planId, action, days } = await req.json();
if (!planId) throw new ApiError("planId 不能为空");
const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } });
if (!plan) throw new ApiError("计划不存在", 404);
if (plan.userId !== userId) throw new ApiError("只能操作自己的计划", 403);
const act = action || "accept";
if (act === "accept") {
if (plan.status !== "active") throw new ApiError("该计划无法接受", 400);
const endTime = computeEndTime(plan.planData, new Date());
await prisma.weekendPlan.update({
where: { id: planId },
data: { status: "accepted", endTime },
});
return NextResponse.json({ ok: true, endTime });
}
if (act === "complete" || act === "expire") {
if (plan.status !== "accepted") throw new ApiError("只能更新已接受的计划", 400);
await prisma.weekendPlan.update({
where: { id: planId },
data: { status: act === "complete" ? "completed" : "expired" },
});
return NextResponse.json({ ok: true });
}
throw new ApiError("无效的操作", 400);
const result = await handlePlanUpdate(planId, userId, action, days);
return NextResponse.json(result);
});
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);
if (mode === "latest") {
const roomId = searchParams.get("roomId");
if (!roomId) throw new ApiError("roomId 不能为空");
const plan = await prisma.weekendPlan.findFirst({
where: { roomId, userId: userId!, status: "accepted" },
orderBy: { createdAt: "desc" },
select: { id: true, planData: true, endTime: true, createdAt: true },
});
if (!plan) return NextResponse.json({ plan: null });
const parsed = JSON.parse(plan.planData);
return NextResponse.json({
plan: { id: plan.id, days: parsed.days, endTime: plan.endTime, createdAt: plan.createdAt },
});
return NextResponse.json(await getLatestPlan(roomId, userId));
}
if (mode === "pending") {
const plans = await prisma.weekendPlan.findMany({
where: {
userId: userId!,
status: "accepted",
endTime: { not: null, lt: new Date() },
},
orderBy: { createdAt: "desc" },
select: { id: true, planData: true, roomId: true, createdAt: true },
take: 5,
});
const result = await Promise.all(
plans.map(async (p) => {
const room = await prisma.blindBoxRoom.findUnique({
where: { id: p.roomId },
select: { name: true, code: true },
});
const parsed = JSON.parse(p.planData);
const days = parsed.days as { date: string; items: { activity: string }[] }[];
return {
id: p.id,
roomName: room?.name ?? "未知房间",
roomCode: room?.code ?? "",
date: days.map((d) => d.date).join(" + "),
activities: days.flatMap((d) => d.items.map((i) => i.activity)),
createdAt: p.createdAt,
};
}),
);
return NextResponse.json({ pending: result });
return NextResponse.json(await getPendingPlans(userId));
}
if (mode === "history") {
const plans = await prisma.weekendPlan.findMany({
where: {
userId: userId!,
status: { in: ["completed", "expired"] },
},
orderBy: { createdAt: "desc" },
select: { id: true, planData: true, status: true, roomId: true, createdAt: true },
take: 50,
});
const result = await Promise.all(
plans.map(async (p) => {
const room = await prisma.blindBoxRoom.findUnique({
where: { id: p.roomId },
select: { name: true, code: true },
});
const parsed = JSON.parse(p.planData);
const days = parsed.days as { date: string; items: { activity: string }[] }[];
return {
id: p.id,
status: p.status,
roomName: room?.name ?? "未知房间",
roomCode: room?.code ?? "",
date: days.map((d) => d.date).join(" + "),
dayCount: days.length,
activities: days.flatMap((d) => d.items.map((i) => i.activity)),
createdAt: p.createdAt,
};
}),
);
return NextResponse.json({ history: result });
return NextResponse.json(await getHistoryPlans(userId));
}
throw new ApiError("无效的 mode 参数", 400);
@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/blindboxPlanGen", () => ({
runPlanGeneration: vi.fn(),
}));
import { POST } from "./route";
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
import { getAuthUserId } from "@/lib/auth";
import { ApiError } from "@/lib/api";
const mockRunPlan = vi.mocked(runPlanGeneration);
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
async function readStream(response: Response): Promise<string> {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let text = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
return text;
}
describe("POST /api/blindbox/plan/stream", () => {
it("streams plan generation with SSE events", async () => {
mockRunPlan.mockImplementation(async (_roomId, _userId, _at, onProgress) => {
onProgress?.("正在搜索周边...");
onProgress?.("正在生成行程...");
return {
id: "plan-1",
days: [{ date: "周六", items: [], summary: "轻松逛吃" }],
createdAt: "2025-03-01T09:00:00.000Z",
};
});
const req = new Request("http://localhost/api/blindbox/plan/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: "bb-room-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
}),
});
const res = await POST(req as never);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
const text = await readStream(res);
expect(text).toContain("event: status");
expect(text).toContain("正在搜索周边...");
expect(text).toContain("event: plan");
expect(text).toContain("plan-1");
});
it("streams error event on generation failure", async () => {
mockRunPlan.mockRejectedValue(new Error("AI 服务不可用"));
const req = new Request("http://localhost/api/blindbox/plan/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: "bb-room-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
}),
});
const res = await POST(req as never);
const text = await readStream(res);
expect(text).toContain("event: error");
expect(text).toContain("AI 服务不可用");
});
it("returns 400 for missing roomId", async () => {
const req = new Request("http://localhost/api/blindbox/plan/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
}),
});
const res = await POST(req as never);
expect(res.status).toBe(400);
});
it("returns 400 for invalid available time", async () => {
const req = new Request("http://localhost/api/blindbox/plan/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: "bb-room-1",
availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 },
}),
});
const res = await POST(req as never);
expect(res.status).toBe(400);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = new Request("http://localhost/api/blindbox/plan/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: "bb-room-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
}),
});
const res = await POST(req as never);
expect(res.status).toBe(401);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { NextRequest } from "next/server";
import { requireMembership } from "@/lib/blindbox";
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: NextRequest): Promise<Response> {
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;
availableTime = body.availableTime;
if (!roomId) {
return new Response(
JSON.stringify({ error: "roomId 不能为空" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
await requireMembership(roomId, userId);
const at = availableTime;
if (
!at?.date ||
typeof at.startHour !== "number" ||
typeof at.endHour !== "number" ||
at.endHour <= at.startHour
) {
return new Response(
JSON.stringify({ error: "请选择有效的可用时间" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
} 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,
headers: { "Content-Type": "application/json" },
});
}
const signal = req.signal;
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
let closed = false;
const push = (event: string, data: string) => {
if (closed) return;
controller.enqueue(encoder.encode(encodeSSE(event, data)));
};
const cleanup = () => {
if (closed) return;
closed = true;
clearInterval(heartbeatId);
try { controller.close(); } catch {}
};
// 30s heartbeat to prevent proxy disconnects
const heartbeatId = setInterval(() => {
push("heartbeat", "ping");
}, 30_000);
// Clean up when client disconnects
signal.addEventListener("abort", cleanup);
try {
const result = await runPlanGeneration(roomId, userId, availableTime, (message) => {
push("status", message);
});
push("plan", JSON.stringify({ id: result.id, days: result.days, createdAt: result.createdAt }));
} catch (e) {
const message = e instanceof Error ? e.message : "生成计划失败";
push("error", message);
} finally {
cleanup();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { ApiError } from "@/lib/api";
vi.mock("@/lib/ai", () => ({
suggestAlternativeItems: vi.fn(),
}));
vi.mock("@/lib/amap", () => ({
searchPois: vi.fn(),
}));
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn(),
}));
import { POST } from "./route";
import { suggestAlternativeItems } from "@/lib/ai";
import { searchPois } from "@/lib/amap";
import { getAuthUserId } from "@/lib/auth";
const mockSuggestAlternativeItems = vi.mocked(suggestAlternativeItems);
const mockSearchPois = vi.mocked(searchPois);
const mockGetAuthUserId = vi.mocked(getAuthUserId);
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
vi.clearAllMocks();
});
describe("POST /api/blindbox/plan/suggest-item", () => {
it("returns 401 when not authenticated", async () => {
mockGetAuthUserId.mockRejectedValue(new ApiError("请先登录", 401));
const req = createRequest("/api/blindbox/plan/suggest-item", {
method: "POST",
body: { activity: "看展" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(401);
});
it("returns mapped suggestions for authenticated user", async () => {
mockGetAuthUserId.mockResolvedValue("user-1");
mockSuggestAlternativeItems.mockResolvedValue([
{
activity: "看展",
searchQuery: "上海博物馆",
reason: "交通方便",
},
]);
mockSearchPois.mockResolvedValue([
{
name: "上海博物馆",
address: "黄浦区人民大道201号",
lat: 31.2301,
lng: 121.4737,
},
]);
const req = createRequest("/api/blindbox/plan/suggest-item", {
method: "POST",
body: {
activity: "文艺活动",
time: "14:00",
location: "121.47,31.23",
},
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.suggestions).toHaveLength(1);
expect(data.suggestions[0]).toMatchObject({
activity: "看展",
poi: "上海博物馆",
address: "黄浦区人民大道201号",
lat: 31.2301,
lng: 121.4737,
reason: "交通方便",
});
});
});
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api";
import { suggestAlternativeItems } from "@/lib/ai";
import { searchPois } from "@/lib/amap";
import { getAuthUserId } from "@/lib/auth";
export const POST = apiHandler(async (req) => {
await getAuthUserId(req);
const { activity, time, location } = await req.json();
if (!activity) throw new ApiError("activity 不能为空", 400);
const alts = await suggestAlternativeItems(activity, time ?? "");
if (!alts) throw new ApiError("AI 推荐失败,请重试", 500);
// Parse location "lng,lat"
let anchorLat = 31.23, anchorLng = 121.47; // default Shanghai
if (location) {
const [lng, lat] = location.split(",").map(Number);
if (!isNaN(lat) && !isNaN(lng)) { anchorLat = lat; anchorLng = lng; }
}
// Parallel POI search with per-item fallback
const results = await Promise.all(
alts.map(async (alt) => {
try {
const pois = await searchPois(alt.searchQuery, "place", anchorLat, anchorLng);
const top = pois[0];
if (top) {
return {
activity: alt.activity,
poi: top.name,
address: top.address,
lat: top.lat,
lng: top.lng,
reason: alt.reason,
};
}
} catch (e) { console.error("suggest-item: POI search failed, using fallback:", e); }
return {
activity: alt.activity,
poi: alt.searchQuery,
address: "",
lat: 0,
lng: 0,
reason: alt.reason,
};
}),
);
return NextResponse.json({ suggestions: results });
});
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_BLINDBOX_IDEA } from "@/__tests__/helpers/fixtures";
import { ApiError } from "@/lib/api";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/ai", () => ({
tagIdea: vi.fn().mockResolvedValue({
category: "outdoor",
timeSlot: "morning",
estimatedMinutes: 120,
costLevel: "free",
intensity: "active",
needsBooking: false,
searchQuery: "公园",
searchType: "category",
}),
}));
import { POST } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
describe("POST /api/blindbox/retag", () => {
it("retags untagged ideas and returns count", async () => {
const untaggedIdea = { ...TEST_BLINDBOX_IDEA, category: null };
prismaMock.blindBoxIdea.findMany.mockResolvedValue([untaggedIdea] as never);
prismaMock.blindBoxIdea.update.mockResolvedValue(TEST_BLINDBOX_IDEA as never);
const req = createRequest("/api/blindbox/retag", {
method: "POST",
body: { roomId: "bb-room-1" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.retagged).toBe(1);
expect(data.total).toBe(1);
});
it("returns 0 when no untagged ideas", async () => {
prismaMock.blindBoxIdea.findMany.mockResolvedValue([] as never);
const req = createRequest("/api/blindbox/retag", {
method: "POST",
body: { roomId: "bb-room-1" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.retagged).toBe(0);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = createRequest("/api/blindbox/retag", {
method: "POST",
body: { roomId: "bb-room-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(401);
});
});
+46
View File
@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireMembership } from "@/lib/blindbox";
import { apiHandler } from "@/lib/api";
import { getAuthUserId } from "@/lib/auth";
import { tagIdea } from "@/lib/ai";
export const POST = apiHandler(async (req) => {
const userId = await getAuthUserId(req);
const { roomId } = await req.json();
await requireMembership(roomId, userId);
// Find all untagged ideas in this room (any member's ideas)
const untagged = await prisma.blindBoxIdea.findMany({
where: { roomId, status: "in_pool", category: null },
select: { id: true, content: true },
});
if (untagged.length === 0) {
return NextResponse.json({ retagged: 0 });
}
let retagged = 0;
for (const idea of untagged) {
const tags = await tagIdea(idea.content);
if (tags) {
await prisma.blindBoxIdea.update({
where: { id: idea.id },
data: {
category: tags.category,
timeSlot: tags.timeSlot,
estimatedMinutes: tags.estimatedMinutes,
costLevel: tags.costLevel,
intensity: tags.intensity,
needsBooking: tags.needsBooking,
searchQuery: tags.searchQuery,
searchType: tags.searchType,
},
});
retagged++;
}
}
return NextResponse.json({ retagged, total: untagged.length });
});
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({
getRoomByCode: vi.fn(),
requireMembership: vi.fn().mockResolvedValue({}),
}));
import { GET, PATCH, DELETE } from "./route";
import { getRoomByCode } from "@/lib/blindbox";
import { getAuthUserId } from "@/lib/auth";
const mockGetRoomByCode = vi.mocked(getRoomByCode);
beforeEach(() => {
resetPrismaMock();
vi.clearAllMocks();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
describe("GET /api/blindbox/room/[code]", () => {
it("returns room data", async () => {
mockGetRoomByCode.mockResolvedValue({
...TEST_BLINDBOX_ROOM,
_count: { ideas: 3 },
members: [
{ user: { id: "user-1", username: "test", avatar: "🐱" }, joinedAt: new Date() },
],
} as never);
const req = createRequest("/api/blindbox/room/ABC123");
const ctx = createRouteContext({ code: "ABC123" });
const res = await GET(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.code).toBe("ABC123");
expect(data.poolCount).toBe(3);
expect(data.members).toHaveLength(1);
});
it("returns 404 for nonexistent room", async () => {
mockGetRoomByCode.mockResolvedValue(null);
const req = createRequest("/api/blindbox/room/BADCODE");
const ctx = createRouteContext({ code: "BADCODE" });
const res = await GET(req, ctx);
expect(res.status).toBe(404);
});
});
describe("PATCH /api/blindbox/room/[code]", () => {
it("updates room location", async () => {
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
prismaMock.blindBoxRoom.update.mockResolvedValue({
...TEST_BLINDBOX_ROOM,
city: "上海",
lat: 31.2,
lng: 121.4,
} as never);
const req = createRequest("/api/blindbox/room/ABC123", {
method: "PATCH",
body: { city: "上海", lat: 31.2, lng: 121.4 },
});
const ctx = createRouteContext({ code: "ABC123" });
const res = await PATCH(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.city).toBe("上海");
});
it("returns 400 for invalid coordinates", async () => {
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
const req = createRequest("/api/blindbox/room/ABC123", {
method: "PATCH",
body: { lat: 999, lng: 121.4 },
});
const ctx = createRouteContext({ code: "ABC123" });
const res = await PATCH(req, ctx);
expect(res.status).toBe(400);
});
});
describe("DELETE /api/blindbox/room/[code]", () => {
it("deletes room when creator", async () => {
// user-1 is creator (TEST_BLINDBOX_ROOM.creatorId = "user-1")
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
prismaMock.blindBoxRoom.delete.mockResolvedValue({} as never);
const req = createRequest("/api/blindbox/room/ABC123", { method: "DELETE", body: {} });
const ctx = createRouteContext({ code: "ABC123" });
const res = await DELETE(req, ctx);
const { data } = await parseJsonResponse(res);
expect(data.action).toBe("deleted");
});
it("leaves room when not creator", async () => {
// Authenticate as user-2 (not creator)
vi.mocked(getAuthUserId).mockResolvedValueOnce("user-2");
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
prismaMock.blindBoxMember.findUnique.mockResolvedValue({ id: "member-2" } as never);
prismaMock.blindBoxMember.delete.mockResolvedValue({} as never);
const req = createRequest("/api/blindbox/room/ABC123", { method: "DELETE", body: {} });
const ctx = createRouteContext({ code: "ABC123" });
const res = await DELETE(req, ctx);
const { data } = await parseJsonResponse(res);
expect(data.action).toBe("left");
});
it("returns 403 when not a member and not creator", async () => {
// Authenticate as stranger (not creator, not member)
vi.mocked(getAuthUserId).mockResolvedValueOnce("stranger");
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
prismaMock.blindBoxMember.findUnique.mockResolvedValue(null as never);
const req = createRequest("/api/blindbox/room/ABC123", { method: "DELETE", body: {} });
const ctx = createRouteContext({ code: "ABC123" });
const res = await DELETE(req, ctx);
expect(res.status).toBe(403);
});
});
+8 -7
View File
@@ -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;
@@ -15,6 +16,7 @@ export const GET = apiHandler(async (_req, { params }) => {
name: room.name,
creatorId: room.creatorId,
city: room.city,
address: room.address,
lat: room.lat,
lng: room.lng,
poolCount: room._count.ideas,
@@ -26,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, 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() },
@@ -51,6 +52,7 @@ export const PATCH = apiHandler(async (req, { params }) => {
where: { id: room.id },
data: {
city: typeof city === "string" ? city.trim() : null,
address: typeof address === "string" ? address.trim() : null,
lat: numLat,
lng: numLng,
},
@@ -58,16 +60,15 @@ export const PATCH = apiHandler(async (req, { params }) => {
return NextResponse.json({
city: updated.city,
address: updated.address,
lat: updated.lat,
lng: updated.lng,
});
});
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() },
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
import { POST } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
});
describe("POST /api/blindbox/room/join", () => {
it("joins a room by code", async () => {
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
prismaMock.blindBoxMember.findUnique.mockResolvedValue(null as never);
prismaMock.blindBoxMember.create.mockResolvedValue({} as never);
const req = createRequest("/api/blindbox/room/join", {
method: "POST",
body: { code: "ABC123" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(201);
expect(data.code).toBe("ABC123");
});
it("returns alreadyMember if already joined", async () => {
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
prismaMock.blindBoxMember.findUnique.mockResolvedValue({ id: "member-1" } as never);
const req = createRequest("/api/blindbox/room/join", {
method: "POST",
body: { code: "ABC123" },
});
const res = await POST(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.alreadyMember).toBe(true);
});
it("returns 404 when room code not found", async () => {
prismaMock.blindBoxRoom.findUnique.mockResolvedValue(null as never);
const req = createRequest("/api/blindbox/room/join", {
method: "POST",
body: { code: "BADCODE" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(404);
});
it("returns 400 when code is missing", async () => {
const req = createRequest("/api/blindbox/room/join", {
method: "POST",
body: {},
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
});
+4 -3
View File
@@ -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({
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER, TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures";
import { ApiError } from "@/lib/api";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({
generateUniqueRoomCode: vi.fn().mockResolvedValue("XYZ789"),
}));
import { POST } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
describe("POST /api/blindbox/room", () => {
it("creates a blindbox room", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.blindBoxRoom.create.mockResolvedValue({
...TEST_BLINDBOX_ROOM,
code: "XYZ789",
} as never);
const req = createRequest("/api/blindbox/room", {
method: "POST",
body: { name: "周末计划" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(201);
expect(data.code).toBe("XYZ789");
});
it("uses default room name when not provided", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.blindBoxRoom.create.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
const req = createRequest("/api/blindbox/room", {
method: "POST",
body: {},
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(201);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = createRequest("/api/blindbox/room", {
method: "POST",
body: { name: "test" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(401);
});
it("returns 400 when room name too long", async () => {
const req = createRequest("/api/blindbox/room", {
method: "POST",
body: { name: "a".repeat(31) },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
});
+4 -4
View File
@@ -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);
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { ApiError } from "@/lib/api";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
import { GET } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
describe("GET /api/blindbox/rooms", () => {
it("returns user rooms list", async () => {
prismaMock.blindBoxMember.findMany.mockResolvedValue([
{
room: {
id: "bb-room-1",
code: "ABC123",
name: "周末",
creatorId: "user-1",
_count: { members: 2, ideas: 5 },
members: [
{ user: { id: "user-1", username: "test", avatar: "🐱" } },
],
ideas: [{ content: "去公园", createdAt: new Date() }],
},
joinedAt: new Date(),
},
] as never);
prismaMock.blindBoxIdea.groupBy = vi.fn().mockResolvedValue([
{ roomId: "bb-room-1", _count: 3 },
]) as never;
const req = createRequest("/api/blindbox/rooms");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.rooms).toHaveLength(1);
expect(data.rooms[0].code).toBe("ABC123");
expect(data.rooms[0].poolCount).toBe(3);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = createRequest("/api/blindbox/rooms");
const res = await GET(req, mockCtx);
expect(res.status).toBe(401);
});
});
+4 -2
View File
@@ -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 },
@@ -34,6 +35,7 @@ export const GET = apiHandler(async (req) => {
id: m.room.id,
code: m.room.code,
name: m.room.name,
creatorId: m.room.creatorId,
memberCount: m.room._count.members,
ideaCount: m.room._count.ideas,
poolCount: 0,
+159
View File
@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_BLINDBOX_IDEA } from "@/__tests__/helpers/fixtures";
import { ApiError } from "@/lib/api";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/ai", () => ({
tagIdea: vi.fn().mockResolvedValue({
category: "outdoor",
timeSlot: "morning",
estimatedMinutes: 120,
costLevel: "free",
intensity: "active",
needsBooking: false,
searchQuery: "公园",
searchType: "category",
}),
}));
import { POST, GET, PUT, DELETE } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
describe("POST /api/blindbox (create idea)", () => {
it("creates an idea successfully", async () => {
prismaMock.blindBoxIdea.create.mockResolvedValue(TEST_BLINDBOX_IDEA as never);
prismaMock.blindBoxIdea.update.mockResolvedValue(TEST_BLINDBOX_IDEA as never);
const req = createRequest("/api/blindbox", {
method: "POST",
body: { roomId: "bb-room-1", content: "去公园野餐" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(201);
expect(data.id).toBe("idea-1");
});
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = createRequest("/api/blindbox", {
method: "POST",
body: { roomId: "bb-room-1", content: "test" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(401);
});
it("returns 400 when content is empty", async () => {
const req = createRequest("/api/blindbox", {
method: "POST",
body: { roomId: "bb-room-1", content: "" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 400 when content over 200 chars", async () => {
const req = createRequest("/api/blindbox", {
method: "POST",
body: { roomId: "bb-room-1", content: "a".repeat(201) },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
});
describe("GET /api/blindbox (get pool data)", () => {
it("returns pool data for valid member", async () => {
prismaMock.blindBoxIdea.count.mockResolvedValue(5 as never);
prismaMock.blindBoxIdea.findMany
.mockResolvedValueOnce([TEST_BLINDBOX_IDEA] as never)
.mockResolvedValueOnce([] as never);
const req = createRequest("/api/blindbox?roomId=bb-room-1");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.poolCount).toBe(5);
expect(data.myIdeas).toHaveLength(1);
expect(data.drawn).toHaveLength(0);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = createRequest("/api/blindbox?roomId=bb-room-1");
const res = await GET(req, mockCtx);
expect(res.status).toBe(401);
});
});
describe("PUT /api/blindbox (edit idea)", () => {
it("edits an idea successfully", async () => {
prismaMock.blindBoxIdea.updateMany.mockResolvedValue({ count: 1 } as never);
const req = createRequest("/api/blindbox", {
method: "PUT",
body: { ideaId: "idea-1", content: "去公园散步" },
});
const res = await PUT(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.content).toBe("去公园散步");
});
it("returns 404 when idea not found or already drawn", async () => {
prismaMock.blindBoxIdea.updateMany.mockResolvedValue({ count: 0 } as never);
const req = createRequest("/api/blindbox", {
method: "PUT",
body: { ideaId: "nonexistent", content: "test" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(404);
});
});
describe("DELETE /api/blindbox (delete idea)", () => {
it("deletes an idea successfully", async () => {
prismaMock.blindBoxIdea.deleteMany.mockResolvedValue({ count: 1 } as never);
const req = createRequest("/api/blindbox", {
method: "DELETE",
body: { ideaId: "idea-1" },
});
const res = await DELETE(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.deleted).toBe(true);
});
it("returns 404 when idea not found or not owned", async () => {
prismaMock.blindBoxIdea.deleteMany.mockResolvedValue({ count: 0 } as never);
const req = createRequest("/api/blindbox", {
method: "DELETE",
body: { ideaId: "nonexistent" },
});
const res = await DELETE(req, mockCtx);
expect(res.status).toBe(404);
});
});
+39 -51
View File
@@ -1,16 +1,39 @@
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 = 3000;
const TAG_TIMEOUT_MS = 60_000;
function applyTags(ideaId: string, content: string) {
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), TAG_TIMEOUT_MS));
Promise.race([tagIdea(content), timeout])
.then((tags) => {
if (!tags) return;
return prisma.blindBoxIdea.update({
where: { id: ideaId },
data: {
category: tags.category,
timeSlot: tags.timeSlot,
estimatedMinutes: tags.estimatedMinutes,
costLevel: tags.costLevel,
intensity: tags.intensity,
needsBooking: tags.needsBooking,
searchQuery: tags.searchQuery,
searchType: tags.searchType,
},
});
})
.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);
@@ -20,33 +43,13 @@ export const POST = apiHandler(async (req) => {
data: { roomId, userId, content: trimmedContent },
});
const tags = await Promise.race([
tagIdea(trimmedContent),
new Promise<null>((resolve) => setTimeout(() => resolve(null), TAG_TIMEOUT_MS)),
]);
applyTags(idea.id, trimmedContent);
if (tags) {
await prisma.blindBoxIdea.update({
where: { id: idea.id },
data: {
category: tags.category,
timeSlot: tags.timeSlot,
estimatedMinutes: tags.estimatedMinutes,
outdoor: tags.outdoor,
searchQuery: tags.searchQuery,
searchType: tags.searchType,
},
});
}
return NextResponse.json(
{ id: idea.id, ...tags && { tags } },
{ status: 201 },
);
return NextResponse.json({ id: idea.id }, { status: 201 });
});
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);
@@ -65,7 +68,9 @@ export const GET = apiHandler(async (req) => {
category: true,
timeSlot: true,
estimatedMinutes: true,
outdoor: true,
costLevel: true,
intensity: true,
needsBooking: true,
searchQuery: true,
searchType: true,
},
@@ -84,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);
@@ -97,32 +102,15 @@ export const PUT = apiHandler(async (req) => {
if (count === 0) throw new ApiError("想法不存在、已被抽中或无权编辑", 404);
const tags = await Promise.race([
tagIdea(trimmedContent),
new Promise<null>((resolve) => setTimeout(() => resolve(null), TAG_TIMEOUT_MS)),
]);
applyTags(ideaId, trimmedContent);
if (tags) {
await prisma.blindBoxIdea.updateMany({
where: { id: ideaId, userId, status: "in_pool" },
data: {
category: tags.category,
timeSlot: tags.timeSlot,
estimatedMinutes: tags.estimatedMinutes,
outdoor: tags.outdoor,
searchQuery: tags.searchQuery,
searchType: tags.searchType,
},
});
}
return NextResponse.json({ id: ideaId, content: trimmedContent, ...tags && { tags } });
return NextResponse.json({ id: ideaId, content: trimmedContent });
});
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({
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/ai", () => ({
suggestIdeas: vi.fn().mockResolvedValue(["去爬山", "骑自行车", "看日出", "野餐"]),
}));
import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
});
describe("GET /api/blindbox/suggest", () => {
it("returns AI suggestions when enough ideas exist", async () => {
prismaMock.blindBoxIdea.findMany.mockResolvedValue([
{ content: "去公园" },
{ content: "看电影" },
{ content: "吃火锅" },
] as never);
const req = createRequest("/api/blindbox/suggest?roomId=bb-room-1&userId=user-1");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.suggestions).toHaveLength(4);
expect(data.source).toBe("ai");
});
it("returns empty when less than 2 ideas", async () => {
prismaMock.blindBoxIdea.findMany.mockResolvedValue([
{ content: "去公园" },
] as never);
const req = createRequest("/api/blindbox/suggest?roomId=bb-room-1&userId=user-1");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.suggestions).toHaveLength(0);
expect(data.source).toBe("none");
});
it("returns 400 when roomId missing", async () => {
const req = createRequest("/api/blindbox/suggest?userId=user-1");
const res = await GET(req, mockCtx);
expect(res.status).toBe(400);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireMembership } from "@/lib/blindbox";
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");
if (!roomId) throw new ApiError("roomId 不能为空");
await requireMembership(roomId, userId);
const recentIdeas = await prisma.blindBoxIdea.findMany({
where: { roomId, status: "in_pool" },
orderBy: { createdAt: "desc" },
take: 10,
select: { content: true },
});
if (recentIdeas.length < 2) {
return NextResponse.json({ suggestions: [], source: "none" });
}
const suggestions = await suggestIdeas(recentIdeas.map((i) => i.content));
if (suggestions.length === 0) {
return NextResponse.json({ suggestions: [], source: "none" });
}
return NextResponse.json({ suggestions, source: "ai" });
});
+38
View File
@@ -0,0 +1,38 @@
/**
* Debug endpoint: inspect raw Amap transit API response + parsed result.
* GET /api/debug/transit?olat=31.23&olng=121.47&dlat=31.20&dlng=121.50&city=上海
* Only available in development.
*/
import { NextRequest, NextResponse } from "next/server";
import { getTransitDirection } from "@/lib/amap";
export async function GET(req: NextRequest) {
if (process.env.NODE_ENV !== "development") {
return NextResponse.json({ error: "only available in development" }, { status: 403 });
}
const { searchParams } = req.nextUrl;
const oLat = Number(searchParams.get("olat"));
const oLng = Number(searchParams.get("olng"));
const dLat = Number(searchParams.get("dlat"));
const dLng = Number(searchParams.get("dlng"));
const city = searchParams.get("city") ?? "上海";
if (!oLat || !oLng || !dLat || !dLng) {
return NextResponse.json({ error: "需要 olat/olng/dlat/dlng 参数" }, { status: 400 });
}
const result = await getTransitDirection({
originLat: oLat,
originLng: oLng,
destLat: dLat,
destLng: dLng,
city,
});
if (!result) {
return NextResponse.json({ error: "未找到路线" });
}
return NextResponse.json({ parsed: result });
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
const mockReverseGeocode = vi.fn();
vi.mock("@/lib/amap", () => ({
reverseGeocode: (...args: unknown[]) => mockReverseGeocode(...args),
}));
import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/location/regeo", () => {
it("returns reverse geocoded location", async () => {
mockReverseGeocode.mockResolvedValue({
name: "黄浦区 南京东路街道 人民广场",
formatted: "上海市黄浦区人民大道",
});
const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.name).toContain("黄浦区");
expect(data.formatted).toBe("上海市黄浦区人民大道");
});
it("returns null name when API returns no result", async () => {
mockReverseGeocode.mockResolvedValue({ name: null, formatted: null });
const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.name).toBeNull();
});
it("returns 400 when coordinates missing", async () => {
const req = createRequest("/api/location/regeo");
const res = await GET(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 503 when API unavailable", async () => {
const { ApiError } = await import("@/lib/api");
mockReverseGeocode.mockRejectedValue(new ApiError("位置服务暂时不可用,请稍后重试", 503));
const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47");
const res = await GET(req, mockCtx);
expect(res.status).toBe(503);
});
});
+3 -34
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api";
import { requireAmapApiKey } from "@/lib/amap";
import { reverseGeocode } from "@/lib/amap";
export const GET = apiHandler(async (req) => {
const lat = req.nextUrl.searchParams.get("lat");
@@ -8,37 +8,6 @@ export const GET = apiHandler(async (req) => {
if (!lat || !lng) throw new ApiError("lat and lng are required");
const apiKey = requireAmapApiKey();
const url = new URL("https://restapi.amap.com/v3/geocode/regeo");
url.searchParams.set("key", apiKey);
url.searchParams.set("location", `${lng},${lat}`);
url.searchParams.set("extensions", "base");
let data;
try {
const res = await fetch(url.toString());
data = await res.json();
} catch {
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
}
if (data.status !== "1" || !data.regeocode) {
return NextResponse.json({ name: null });
}
const comp = data.regeocode.addressComponent;
const district = comp?.district || comp?.city || "";
const township = comp?.township || "";
const neighborhood = comp?.neighborhood?.name || "";
const name = [district, township, neighborhood]
.filter(Boolean)
.join(" ")
.trim();
return NextResponse.json({
name: name || data.regeocode.formatted_address || null,
formatted: data.regeocode.formatted_address || null,
});
const result = await reverseGeocode({ lat: Number(lat), lng: Number(lng) });
return NextResponse.json(result);
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
const mockSearchPlaceText = vi.fn();
vi.mock("@/lib/amap", () => ({
searchPlaceText: (...args: unknown[]) => mockSearchPlaceText(...args),
}));
import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/location/search", () => {
it("returns search results", async () => {
mockSearchPlaceText.mockResolvedValue([
{
id: "poi-1",
name: "星巴克",
address: "南京路1号",
lat: 31.2,
lng: 121.4,
rating: 4.5,
cost: 40,
},
]);
const req = createRequest("/api/location/search?keywords=星巴克");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data).toHaveLength(1);
expect(data[0].name).toBe("星巴克");
expect(data[0].lat).toBe(31.2);
expect(data[0].lng).toBe(121.4);
});
it("returns empty when no results", async () => {
mockSearchPlaceText.mockResolvedValue([]);
const req = createRequest("/api/location/search?keywords=不存在的地方");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data).toEqual([]);
});
it("returns 400 when keywords missing", async () => {
const req = createRequest("/api/location/search");
const res = await GET(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 503 when API unavailable", async () => {
const { ApiError } = await import("@/lib/api");
mockSearchPlaceText.mockRejectedValue(new ApiError("位置服务暂时不可用,请稍后重试", 503));
const req = createRequest("/api/location/search?keywords=test");
const res = await GET(req, mockCtx);
expect(res.status).toBe(503);
});
});
+2 -57
View File
@@ -1,19 +1,6 @@
import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api";
import { requireAmapApiKey } from "@/lib/amap";
interface AmapPoiV5 {
id: string;
name: string;
address?: string;
location?: string;
type?: string;
business?: {
rating?: string;
cost?: string;
tel?: string;
};
}
import { searchPlaceText } from "@/lib/amap";
export const GET = apiHandler(async (req) => {
const keywords = req.nextUrl.searchParams.get("keywords")?.trim();
@@ -22,48 +9,6 @@ export const GET = apiHandler(async (req) => {
const city = req.nextUrl.searchParams.get("city")?.trim();
const types = req.nextUrl.searchParams.get("types")?.trim();
const apiKey = requireAmapApiKey();
const url = new URL("https://restapi.amap.com/v5/place/text");
url.searchParams.set("key", apiKey);
url.searchParams.set("keywords", keywords);
url.searchParams.set("show_fields", "business");
url.searchParams.set("page_size", "10");
if (city) url.searchParams.set("region", city);
if (types) url.searchParams.set("types", types);
let data;
try {
const res = await fetch(url.toString());
data = await res.json();
} catch {
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
}
if (data.status !== "1" || !data.pois?.length) {
return NextResponse.json([]);
}
const results = data.pois
.filter((poi: AmapPoiV5) => poi.location)
.map((poi: AmapPoiV5) => {
const [lng, lat] = (poi.location ?? "0,0").split(",").map(Number);
const ratingStr = poi.business?.rating;
const rating = ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || null : null;
const costStr = poi.business?.cost;
const cost = costStr && costStr !== "[]" && costStr !== "0" ? Number(costStr) : null;
return {
id: poi.id,
name: poi.name,
address: poi.address || "",
lat,
lng,
rating,
cost,
};
});
const results = await searchPlaceText({ keywords, city: city || undefined, types: types || undefined });
return NextResponse.json(results);
});
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
const mockGetInputTips = vi.fn();
vi.mock("@/lib/amap", () => ({
getInputTips: (...args: unknown[]) => mockGetInputTips(...args),
}));
import { GET } from "./route";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/location/suggest", () => {
it("returns suggestions", async () => {
mockGetInputTips.mockResolvedValue([
{
id: "tip-1",
name: "人民广场",
district: "黄浦区",
address: "人民大道",
lat: 31.2304,
lng: 121.4737,
},
]);
const req = createRequest("/api/location/suggest?keywords=人民广场");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data).toHaveLength(1);
expect(data[0].name).toBe("人民广场");
});
it("returns empty for no keywords", async () => {
const req = createRequest("/api/location/suggest");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data).toEqual([]);
});
it("filters tips without location", async () => {
mockGetInputTips.mockResolvedValue([
{ id: "tip-1", name: "有位置", district: "", address: "", lat: 31.2, lng: 121.4 },
]);
const req = createRequest("/api/location/suggest?keywords=test");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data).toHaveLength(1);
expect(data[0].name).toBe("有位置");
});
it("returns 503 when API fails", async () => {
const { ApiError } = await import("@/lib/api");
mockGetInputTips.mockRejectedValue(new ApiError("位置服务暂时不可用,请稍后重试", 503));
const req = createRequest("/api/location/suggest?keywords=test");
const res = await GET(req, mockCtx);
expect(res.status).toBe(503);
});
});
+4 -39
View File
@@ -1,47 +1,12 @@
import { NextResponse } from "next/server";
import { apiHandler, ApiError } from "@/lib/api";
import { requireAmapApiKey } from "@/lib/amap";
import { apiHandler } from "@/lib/api";
import { getInputTips } from "@/lib/amap";
export const GET = apiHandler(async (req) => {
const keywords = req.nextUrl.searchParams.get("keywords")?.trim();
if (!keywords) return NextResponse.json([]);
const apiKey = requireAmapApiKey();
const location = req.nextUrl.searchParams.get("location");
const url = new URL("https://restapi.amap.com/v3/assistant/inputtips");
url.searchParams.set("key", apiKey);
url.searchParams.set("keywords", keywords);
url.searchParams.set("datatype", "poi");
if (location) {
url.searchParams.set("location", location);
}
let data;
try {
const res = await fetch(url.toString());
data = await res.json();
} catch {
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
}
if (data.status !== "1" || !data.tips) return NextResponse.json([]);
const suggestions = data.tips
.filter((t: { location?: string }) => t.location && t.location !== "")
.slice(0, 8)
.map((t: { id: string; name: string; district?: string; address?: string; location: string }) => {
const [lng, lat] = t.location.split(",").map(Number);
return {
id: t.id,
name: t.name,
district: t.district || "",
address: t.address || "",
lat,
lng,
};
});
const location = req.nextUrl.searchParams.get("location") || undefined;
const suggestions = await getInputTips({ keywords, location });
return NextResponse.json(suggestions);
});
+21 -11
View File
@@ -1,5 +1,5 @@
import { buildRoomStatus } from "@/lib/buildRoomStatus";
import { getRoomData } from "@/lib/store";
import { getRoomData } from "@/lib/roomRepository";
import { subscribe } from "@/lib/roomEvents";
export const dynamic = "force-dynamic";
@@ -12,15 +12,25 @@ export async function GET(
const url = new URL(req.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return new Response(JSON.stringify({ error: "missing_user_id" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
if (userId) {
const data = await getRoomData(id);
if (data && !data.users.includes(userId)) {
return new Response(JSON.stringify({ error: "not_a_member" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
const data = await getRoomData(id);
if (!data) {
return new Response(JSON.stringify({ error: "room_not_found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
if (!data.users.includes(userId)) {
return new Response(JSON.stringify({ error: "not_a_member" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
const encoder = new TextEncoder();
@@ -59,8 +69,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);
}
});
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_ROOM_DATA } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/roomRepository", () => ({
atomicUpdateRoom: vi.fn(),
}));
vi.mock("@/lib/roomEvents", () => ({
notify: vi.fn(),
}));
import { POST } from "./route";
import { atomicUpdateRoom } from "@/lib/roomRepository";
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
beforeEach(() => {
vi.clearAllMocks();
});
describe("POST /api/room/[id]/join", () => {
it("joins a room successfully", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = { ...TEST_ROOM_DATA, users: ["user-1"] };
return updater(data);
});
const req = createRequest("/api/room/ROOM01/join", {
method: "POST",
body: { userId: "user-2" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.roomId).toBe("ROOM01");
expect(data.userCount).toBe(2);
});
it("returns 403 when kicked", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = { ...TEST_ROOM_DATA, kickedUsers: ["user-2"] };
return updater(data);
});
const req = createRequest("/api/room/ROOM01/join", {
method: "POST",
body: { userId: "user-2" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(403);
});
it("returns 403 when room is locked", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = { ...TEST_ROOM_DATA, locked: true, users: ["user-1"] };
return updater(data);
});
const req = createRequest("/api/room/ROOM01/join", {
method: "POST",
body: { userId: "user-2" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(403);
});
it("returns 404 when room not found", async () => {
mockAtomicUpdate.mockResolvedValue(null);
const req = createRequest("/api/room/ABC123/join", {
method: "POST",
body: { userId: "user-1" },
});
const ctx = createRouteContext({ id: "ABC123" });
const res = await POST(req, ctx);
expect(res.status).toBe(404);
});
it("returns 401 when no userId", async () => {
const req = createRequest("/api/room/ROOM01/join", {
method: "POST",
body: {},
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(401);
});
it("returns 400 when room id format is invalid", async () => {
const req = createRequest("/api/room/1234/join", {
method: "POST",
body: { userId: "user-1" },
});
const ctx = createRouteContext({ id: "1234" });
const res = await POST(req, ctx);
expect(res.status).toBe(400);
});
});
+6 -4
View File
@@ -1,15 +1,17 @@
import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/store";
import { atomicUpdateRoom } from "@/lib/roomRepository";
import { notify } from "@/lib/roomEvents";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
import { validatePanicRoomId } from "@/lib/validation";
export const POST = apiHandler(async (req, { params }) => {
const { id } = await params;
const roomId = validatePanicRoomId(id);
const { userId } = await req.json();
requireUserId(userId);
const updated = await atomicUpdateRoom(id, (data) => {
const updated = await atomicUpdateRoom(roomId, (data) => {
if (data.kickedUsers.includes(userId)) {
throw new ApiError("你已被移出该房间", 403);
}
@@ -24,10 +26,10 @@ export const POST = apiHandler(async (req, { params }) => {
if (!updated) throw new ApiError("房间不存在或已过期", 404);
notify(id);
notify(roomId);
return NextResponse.json({
roomId: id,
roomId,
userCount: updated.users.length,
});
});
+141
View File
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, createRouteContext } from "@/__tests__/helpers/api-test-utils";
import { TEST_ROOM_DATA } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/roomRepository", () => ({
atomicUpdateRoom: vi.fn(),
}));
vi.mock("@/lib/roomEvents", () => ({
notify: vi.fn(),
}));
import { POST } from "./route";
import { atomicUpdateRoom } from "@/lib/roomRepository";
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
beforeEach(() => {
vi.clearAllMocks();
});
describe("POST /api/room/[id]/manage", () => {
it("locks the room", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
const result = updater(data);
expect(result.locked).toBe(true);
return result;
});
const req = createRequest("/api/room/ROOM01/manage", {
method: "POST",
body: { userId: "user-1", action: "lock" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(200);
});
it("unlocks the room", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.locked = true;
const result = updater(data);
expect(result.locked).toBe(false);
return result;
});
const req = createRequest("/api/room/ROOM01/manage", {
method: "POST",
body: { userId: "user-1", action: "unlock" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(200);
});
it("kicks a user", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
const result = updater(data);
expect(result.users).not.toContain("user-2");
expect(result.kickedUsers).toContain("user-2");
return result;
});
const req = createRequest("/api/room/ROOM01/manage", {
method: "POST",
body: { userId: "user-1", action: "kick", targetUserId: "user-2" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(200);
});
it("prevents kicking yourself", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
return updater(data);
});
const req = createRequest("/api/room/ROOM01/manage", {
method: "POST",
body: { userId: "user-1", action: "kick", targetUserId: "user-1" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(400);
});
it("ends voting by setting all swipeCounts to total", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
const result = updater(data);
expect(result.swipeCounts["user-1"]).toBe(3);
expect(result.swipeCounts["user-2"]).toBe(3);
return result;
});
const req = createRequest("/api/room/ROOM01/manage", {
method: "POST",
body: { userId: "user-1", action: "end_voting" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(200);
});
it("returns 403 when not the creator", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.creatorId = "other-user";
return updater(data);
});
const req = createRequest("/api/room/ROOM01/manage", {
method: "POST",
body: { userId: "user-1", action: "lock" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(403);
});
it("returns 400 for unknown action", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
return updater(data);
});
const req = createRequest("/api/room/ROOM01/manage", {
method: "POST",
body: { userId: "user-1", action: "unknown" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(400);
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/store";
import { atomicUpdateRoom } from "@/lib/roomRepository";
import { notify } from "@/lib/roomEvents";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/roomRepository", () => ({
atomicUpdateRoom: vi.fn(),
}));
vi.mock("@/lib/roomEvents", () => ({
notify: vi.fn(),
}));
import { POST } from "./route";
import { atomicUpdateRoom } from "@/lib/roomRepository";
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
beforeEach(() => {
vi.clearAllMocks();
});
describe("POST /api/room/[id]/reset", () => {
it("resets the room (clears likes/swipeCounts/match)", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.likes = { "rest-1": ["user-1"] };
data.swipeCounts = { "user-1": 3 };
data.match = "rest-1";
const result = updater(data);
expect(result.likes).toEqual({});
expect(result.swipeCounts).toEqual({});
expect(result.match).toBeNull();
return result;
});
const req = createRequest("/api/room/ROOM01/reset", {
method: "POST",
body: { userId: "user-1" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.ok).toBe(true);
});
it("filters restaurants when restaurantIds provided", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
const result = updater(data);
expect(result.restaurants).toHaveLength(1);
expect(result.restaurants[0].id).toBe(TEST_RESTAURANT.id);
return result;
});
const req = createRequest("/api/room/ROOM01/reset", {
method: "POST",
body: { userId: "user-1", restaurantIds: [TEST_RESTAURANT.id] },
});
const ctx = createRouteContext({ id: "ROOM01" });
await POST(req, ctx);
});
it("returns 403 when not a member or creator", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.users = ["other-user"];
data.creatorId = "other-user";
return updater(data);
});
const req = createRequest("/api/room/ROOM01/reset", {
method: "POST",
body: { userId: "user-1" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(403);
});
it("returns 404 when room not found", async () => {
mockAtomicUpdate.mockResolvedValue(null);
const req = createRequest("/api/room/NONEXIST/reset", {
method: "POST",
body: { userId: "user-1" },
});
const ctx = createRouteContext({ id: "NONEXIST" });
const res = await POST(req, ctx);
expect(res.status).toBe(404);
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/store";
import { atomicUpdateRoom } from "@/lib/roomRepository";
import { notify } from "@/lib/roomEvents";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
vi.mock("@/lib/prisma", () => ({
prisma: { user: { findMany: vi.fn().mockResolvedValue([]) } },
}));
vi.mock("@/lib/buildRoomStatus", () => ({
buildRoomStatus: vi.fn(),
}));
import { GET } from "./route";
import { buildRoomStatus } from "@/lib/buildRoomStatus";
const mockBuildRoomStatus = vi.mocked(buildRoomStatus);
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/room/[id]", () => {
it("returns room status", async () => {
mockBuildRoomStatus.mockResolvedValue({
roomId: "ROOM01",
userCount: 2,
match: null,
matchType: null,
matchLikes: 0,
runnerUps: [],
likeCounts: {},
swipeCounts: {},
restaurants: [],
creatorId: "user-1",
locked: false,
users: ["user-1"],
userProfiles: {},
scene: "eat",
});
const req = createRequest("/api/room/ROOM01");
const ctx = createRouteContext({ id: "ROOM01" });
const res = await GET(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.roomId).toBe("ROOM01");
});
it("returns 404 for nonexistent room", async () => {
mockBuildRoomStatus.mockResolvedValue(null);
const req = createRequest("/api/room/NONEXIST");
const ctx = createRouteContext({ id: "NONEXIST" });
const res = await GET(req, ctx);
expect(res.status).toBe(404);
});
});
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/roomRepository", () => ({
atomicUpdateRoom: vi.fn(),
}));
vi.mock("@/lib/roomEvents", () => ({
notify: vi.fn(),
}));
import { POST } from "./route";
import { atomicUpdateRoom } from "@/lib/roomRepository";
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
beforeEach(() => {
vi.clearAllMocks();
});
describe("POST /api/room/[id]/swipe", () => {
it("records a like action", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
return updater(data);
});
const req = createRequest("/api/room/ROOM01/swipe", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "like" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.likeCount).toBe(1);
});
it("records a pass action", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
return updater(data);
});
const req = createRequest("/api/room/ROOM01/swipe", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "pass" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.match).toBeNull();
});
it("sets match when all users like same restaurant", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.likes[TEST_RESTAURANT.id] = ["user-2"];
return updater(data);
});
const req = createRequest("/api/room/ROOM01/swipe", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "like" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
const { data } = await parseJsonResponse(res);
expect(data.match).toBe(TEST_RESTAURANT.id);
expect(data.likeCount).toBe(2);
});
it("returns 403 when user is not a member", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.users = ["other-user"];
return updater(data);
});
const req = createRequest("/api/room/ROOM01/swipe", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "like" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(403);
});
it("returns 400 for invalid action", async () => {
const req = createRequest("/api/room/ROOM01/swipe", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "invalid" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(400);
});
it("returns 400 when missing restaurantId", async () => {
const req = createRequest("/api/room/ROOM01/swipe", {
method: "POST",
body: { userId: "user-1", action: "like" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(400);
});
it("returns 400 when restaurantId is not in room candidates", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
return updater(data);
});
const req = createRequest("/api/room/ROOM01/swipe", {
method: "POST",
body: { userId: "user-1", restaurantId: "unknown-id", action: "like" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(400);
});
it("returns 404 when room not found", async () => {
mockAtomicUpdate.mockResolvedValue(null);
const req = createRequest("/api/room/ROOM01/swipe", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "like" },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(404);
});
});
+4 -2
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/store";
import { atomicUpdateRoom } from "@/lib/roomRepository";
import { notify } from "@/lib/roomEvents";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
@@ -23,8 +23,10 @@ export const POST = apiHandler(async (req, { params }) => {
}
const restaurantIndex = data.restaurants.findIndex((r) => r.id === rid);
if (restaurantIndex < 0) {
throw new ApiError("restaurantId 不存在于该房间候选列表", 400);
}
const alreadySwiped =
restaurantIndex >= 0 &&
restaurantIndex < (data.swipeCounts[userId] ?? 0);
if (alreadySwiped) return data;
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/roomRepository", () => ({
atomicUpdateRoom: vi.fn(),
}));
vi.mock("@/lib/roomEvents", () => ({
notify: vi.fn(),
}));
import { POST } from "./route";
import { atomicUpdateRoom } from "@/lib/roomRepository";
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
beforeEach(() => {
vi.clearAllMocks();
});
describe("POST /api/room/[id]/undo", () => {
it("undoes a like and decrements swipe count", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.likes[TEST_RESTAURANT.id] = ["user-1"];
data.swipeCounts["user-1"] = 1;
return updater(data);
});
const req = createRequest("/api/room/ROOM01/undo", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.ok).toBe(true);
});
it("clears match when undoing the matched restaurant", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.match = TEST_RESTAURANT.id;
data.likes[TEST_RESTAURANT.id] = ["user-1", "user-2"];
data.swipeCounts["user-1"] = 1;
const result = updater(data);
expect(result.match).toBeNull();
return result;
});
const req = createRequest("/api/room/ROOM01/undo", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id },
});
const ctx = createRouteContext({ id: "ROOM01" });
await POST(req, ctx);
});
it("returns 403 when user is not a member", async () => {
mockAtomicUpdate.mockImplementation(async (_id, updater) => {
const data = structuredClone(TEST_ROOM_DATA);
data.users = ["other-user"];
return updater(data);
});
const req = createRequest("/api/room/ROOM01/undo", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id },
});
const ctx = createRouteContext({ id: "ROOM01" });
const res = await POST(req, ctx);
expect(res.status).toBe(403);
});
it("returns 404 when room not found", async () => {
mockAtomicUpdate.mockResolvedValue(null);
const req = createRequest("/api/room/NONEXIST/undo", {
method: "POST",
body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id },
});
const ctx = createRouteContext({ id: "NONEXIST" });
const res = await POST(req, ctx);
expect(res.status).toBe(404);
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/store";
import { atomicUpdateRoom } from "@/lib/roomRepository";
import { notify } from "@/lib/roomEvents";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
+137
View File
@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
vi.mock("@/lib/prisma", () => ({
prisma: {},
}));
vi.mock("@/lib/roomRepository", () => ({
createRoom: vi.fn().mockResolvedValue("ROOM01"),
}));
vi.mock("@/lib/amap", () => ({
requireAmapApiKey: vi.fn().mockReturnValue("test-key"),
}));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
import { POST } from "./route";
import { createRoom } from "@/lib/roomRepository";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
vi.clearAllMocks();
});
describe("POST /api/room/create", () => {
it("creates a room with restaurants from Amap", async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
status: "1",
pois: [
{
id: "poi-1",
name: "好吃餐厅",
distance: "300",
type: "餐饮服务;中餐厅;川菜",
address: "测试路1号",
location: "121.4,31.2",
business: { rating: "4.5", cost: "80" },
photos: [{ url: "https://img.example.com/1.jpg" }],
},
],
}),
});
const req = createRequest("/api/room/create", {
method: "POST",
body: { lat: 31.2, lng: 121.4, userId: "user-1" },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.roomId).toBe("ROOM01");
expect(data.restaurants).toHaveLength(1);
expect(data.restaurants[0].name).toBe("好吃餐厅");
expect(createRoom).toHaveBeenCalled();
});
it("returns 400 for invalid coordinates", async () => {
const req = createRequest("/api/room/create", {
method: "POST",
body: { lat: "invalid", lng: 121.4, userId: "user-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 400 for out-of-range coordinates", async () => {
const req = createRequest("/api/room/create", {
method: "POST",
body: { lat: 100, lng: 121.4, userId: "user-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 404 when no restaurants found", async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ status: "1", pois: [] }),
});
const req = createRequest("/api/room/create", {
method: "POST",
body: { lat: 31.2, lng: 121.4, userId: "user-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(404);
});
it("returns 503 when Amap API fails", async () => {
mockFetch.mockRejectedValue(new Error("network error"));
const req = createRequest("/api/room/create", {
method: "POST",
body: { lat: 31.2, lng: 121.4, userId: "user-1" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(503);
});
it("filters restaurants by price range", async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
status: "1",
pois: [
{
id: "poi-1",
name: "便宜店",
location: "121.4,31.2",
business: { cost: "30" },
},
{
id: "poi-2",
name: "贵店",
location: "121.4,31.2",
business: { cost: "150" },
},
],
}),
});
const req = createRequest("/api/room/create", {
method: "POST",
body: { lat: 31.2, lng: 121.4, userId: "user-1", priceRange: "under50" },
});
const res = await POST(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.restaurants).toHaveLength(1);
expect(data.restaurants[0].name).toBe("便宜店");
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { createRoom } from "@/lib/store";
import { createRoom } from "@/lib/roomRepository";
import { Restaurant, SceneType } from "@/types";
import { getSceneConfig } from "@/lib/sceneConfig";
import { apiHandler, ApiError } from "@/lib/api";
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
import { ApiError } from "@/lib/api";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
import { GET } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
describe("GET /api/user/achievements", () => {
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = createRequest("/api/user/achievements");
const res = await GET(req, mockCtx);
expect(res.status).toBe(401);
});
it("returns stats and records", async () => {
prismaMock.decision.findMany.mockResolvedValue([
{
id: "dec-1",
userId: "user-1",
roomId: "room-1",
restaurantName: "测试餐厅",
restaurantData: JSON.stringify(TEST_RESTAURANT),
matchType: "unanimous",
participants: 2,
createdAt: new Date("2025-01-01"),
},
] as never);
prismaMock.weekendPlan.findMany.mockResolvedValue([
{
id: "plan-1",
planData: JSON.stringify({
days: [{ date: "周六", items: [{ activity: "逛公园" }] }],
}),
status: "completed",
roomId: "bb-room-1",
createdAt: new Date("2025-01-01"),
},
] as never);
prismaMock.blindBoxRoom.findMany.mockResolvedValue([
{ id: "bb-room-1", name: "周末", code: "ABC123" },
] as never);
const req = createRequest("/api/user/achievements");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.stats.totalDecisions).toBe(1);
expect(data.stats.totalContracts).toBe(1);
expect(data.stats.completedContracts).toBe(1);
expect(data.stats.completionRate).toBe(100);
expect(data.decisions).toHaveLength(1);
expect(data.contracts).toHaveLength(1);
expect(data.contracts[0].roomName).toBe("周末");
});
});
+5 -5
View File
@@ -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" },
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
import { ApiError } from "@/lib/api";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
import { GET, POST, DELETE } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
describe("GET /api/user/favorite", () => {
it("returns empty array when no favorites", async () => {
prismaMock.favorite.findMany.mockResolvedValue([] as never);
const req = createRequest("/api/user/favorite");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data).toEqual([]);
});
it("returns favorites list", async () => {
prismaMock.favorite.findMany.mockResolvedValue([
{
id: "fav-1",
userId: "user-1",
restaurantId: "rest-1",
restaurantData: JSON.stringify(TEST_RESTAURANT),
createdAt: new Date("2025-01-01"),
},
] as never);
const req = createRequest("/api/user/favorite");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data).toHaveLength(1);
expect(data[0].id).toBe("fav-1");
expect(data[0].restaurantData.name).toBe("测试餐厅");
});
});
describe("POST /api/user/favorite", () => {
it("adds a favorite", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.favorite.create.mockResolvedValue({ id: "fav-new" } as never);
const req = createRequest("/api/user/favorite", {
method: "POST",
body: { restaurant: TEST_RESTAURANT },
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.id).toBe("fav-new");
});
it("returns alreadyExists on duplicate", async () => {
const { Prisma } = await import("@prisma/client");
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.favorite.create.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique", {
code: "P2002",
clientVersion: "5.0.0",
}),
);
prismaMock.favorite.findFirst.mockResolvedValue({ id: "fav-existing" } as never);
const req = createRequest("/api/user/favorite", {
method: "POST",
body: { restaurant: TEST_RESTAURANT },
});
const res = await POST(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.alreadyExists).toBe(true);
expect(data.id).toBe("fav-existing");
});
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = createRequest("/api/user/favorite", {
method: "POST",
body: { restaurant: TEST_RESTAURANT },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(401);
});
it("returns 400 when no restaurant", async () => {
const req = createRequest("/api/user/favorite", {
method: "POST",
body: {},
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
});
describe("DELETE /api/user/favorite", () => {
it("deletes a favorite", async () => {
prismaMock.favorite.findUnique.mockResolvedValue({
id: "fav-1",
userId: "user-1",
} as never);
prismaMock.favorite.delete.mockResolvedValue({} as never);
const req = createRequest("/api/user/favorite", {
method: "DELETE",
body: { favoriteId: "fav-1" },
});
const res = await DELETE(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.ok).toBe(true);
});
it("returns 404 when favorite not found", async () => {
prismaMock.favorite.findUnique.mockResolvedValue(null as never);
const req = createRequest("/api/user/favorite", {
method: "DELETE",
body: { favoriteId: "nonexistent" },
});
const res = await DELETE(req, mockCtx);
expect(res.status).toBe(404);
});
it("returns 404 when favorite belongs to another user", async () => {
prismaMock.favorite.findUnique.mockResolvedValue({
id: "fav-1",
userId: "other-user",
} as never);
const req = createRequest("/api/user/favorite", {
method: "DELETE",
body: { favoriteId: "fav-1" },
});
const res = await DELETE(req, mockCtx);
expect(res.status).toBe(404);
});
});
+7 -7
View File
@@ -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 } });
+134
View File
@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
import { ApiError } from "@/lib/api";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
import { GET, POST } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
describe("GET /api/user/history", () => {
it("returns empty array when no history", async () => {
prismaMock.decision.findMany.mockResolvedValue([] as never);
const req = createRequest("/api/user/history");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data).toEqual([]);
});
it("returns decision history", async () => {
prismaMock.decision.findMany.mockResolvedValue([
{
id: "dec-1",
userId: "user-1",
roomId: "room-1",
restaurantName: "测试餐厅",
restaurantData: JSON.stringify(TEST_RESTAURANT),
matchType: "unanimous",
participants: 2,
createdAt: new Date("2025-01-01"),
},
] as never);
const req = createRequest("/api/user/history");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data).toHaveLength(1);
expect(data[0].restaurantName).toBe("测试餐厅");
});
});
describe("POST /api/user/history", () => {
it("saves a decision", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.decision.findFirst.mockResolvedValue(null as never);
prismaMock.decision.create.mockResolvedValue({ id: "dec-new" } as never);
prismaMock.decision.count.mockResolvedValue(1 as never);
const req = createRequest("/api/user/history", {
method: "POST",
body: {
roomId: "room-1",
restaurant: TEST_RESTAURANT,
matchType: "unanimous",
participants: 2,
},
});
const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.id).toBe("dec-new");
});
it("returns alreadyExists for duplicate room", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.decision.findFirst.mockResolvedValue({ id: "existing" } as never);
const req = createRequest("/api/user/history", {
method: "POST",
body: {
roomId: "room-1",
restaurant: TEST_RESTAURANT,
matchType: "unanimous",
},
});
const res = await POST(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.alreadyExists).toBe(true);
});
it("trims history to 50 records", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.decision.findFirst.mockResolvedValue(null as never);
prismaMock.decision.create.mockResolvedValue({ id: "dec-new" } as never);
prismaMock.decision.count.mockResolvedValue(51 as never);
prismaMock.decision.findMany.mockResolvedValue([{ id: "old-1" }] as never);
prismaMock.decision.deleteMany.mockResolvedValue({ count: 1 } as never);
const req = createRequest("/api/user/history", {
method: "POST",
body: {
roomId: "room-1",
restaurant: TEST_RESTAURANT,
matchType: "best",
},
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(200);
expect(prismaMock.decision.deleteMany).toHaveBeenCalled();
});
it("returns 400 when missing required fields", async () => {
const req = createRequest("/api/user/history", {
method: "POST",
body: {},
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = createRequest("/api/user/history", {
method: "POST",
body: { roomId: "room-1", restaurant: TEST_RESTAURANT, matchType: "best" },
});
const res = await POST(req, mockCtx);
expect(res.status).toBe(401);
});
});
+5 -6
View File
@@ -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("缺少必要字段");
}
+182
View File
@@ -0,0 +1,182 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER } from "@/__tests__/helpers/fixtures";
import { ApiError } from "@/lib/api";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("bcryptjs", () => ({
default: {
compare: vi.fn(),
hash: vi.fn().mockResolvedValue("$2a$10$newhash"),
},
}));
import bcrypt from "bcryptjs";
import { GET, PUT } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => {
resetPrismaMock();
vi.mocked(bcrypt.compare).mockReset();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
});
describe("GET /api/user", () => {
it("returns null when no userId provided", async () => {
const req = createRequest("/api/user");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data).toBeNull();
});
it("returns null when user not found", async () => {
prismaMock.user.findUnique.mockResolvedValue(null as never);
const req = createRequest("/api/user?id=nonexistent");
const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data).toBeNull();
});
it("returns user info with decision count", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.decision.count.mockResolvedValue(5 as never);
const req = createRequest("/api/user?id=user-1");
const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.id).toBe("user-1");
expect(data.username).toBe("testuser");
expect(data.decisionCount).toBe(5);
});
});
describe("PUT /api/user", () => {
it("updates username", async () => {
prismaMock.user.findUnique
.mockResolvedValueOnce(TEST_USER as never)
.mockResolvedValueOnce(null as never);
prismaMock.user.update.mockResolvedValue({
...TEST_USER,
username: "newname",
preferences: "{}",
} as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { username: "newname" },
});
const res = await PUT(req, mockCtx);
const { status, data } = await parseJsonResponse(res);
expect(status).toBe(200);
expect(data.username).toBe("newname");
});
it("returns 409 when new username is taken", async () => {
prismaMock.user.findUnique
.mockResolvedValueOnce(TEST_USER as never)
.mockResolvedValueOnce({ id: "other" } as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { username: "takenname" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(409);
});
it("updates password with correct current password", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
prismaMock.user.update.mockResolvedValue({ ...TEST_USER, preferences: "{}" } as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { currentPassword: "old", newPassword: "newpass123" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(200);
});
it("returns 403 when current password is wrong", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
vi.mocked(bcrypt.compare).mockResolvedValue(false as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { currentPassword: "wrong", newPassword: "newpass123" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(403);
});
it("returns 400 when no current password for password change", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { newPassword: "newpass123" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(400);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401));
const req = createRequest("/api/user", {
method: "PUT",
body: { username: "test" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(401);
});
it("updates avatar", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.user.update.mockResolvedValue({ ...TEST_USER, avatar: "🦊", preferences: "{}" } as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { avatar: "🦊" },
});
const res = await PUT(req, mockCtx);
const { data } = await parseJsonResponse(res);
expect(data.avatar).toBe("🦊");
});
it("updates email with validation", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
prismaMock.user.update.mockResolvedValue({
...TEST_USER,
email: "new@example.com",
preferences: "{}",
} as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { email: "new@example.com" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(200);
});
it("rejects invalid email", async () => {
prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never);
const req = createRequest("/api/user", {
method: "PUT",
body: { email: "notanemail" },
});
const res = await PUT(req, mockCtx);
expect(res.status).toBe(400);
});
});
+20 -9
View File
@@ -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<string, unknown> = {};
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, waitFor } from "@testing-library/react";
import React from "react";
import { ToastContext, type ToastContextValue } from "@/hooks/useToast";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
back: vi.fn(),
}),
useParams: () => ({ code: "ABC123" }),
}));
vi.mock("@/lib/userId", () => ({
getCachedProfile: vi.fn().mockReturnValue({ id: "u1", username: "test", avatar: "🐱" }),
isRegistered: vi.fn().mockReturnValue(true),
}));
vi.mock("@/hooks/useShare", () => ({
useShare: () => ({
copyToClipboard: vi.fn().mockResolvedValue(true),
share: vi.fn(),
}),
}));
vi.mock("canvas-confetti", () => ({
default: vi.fn(),
}));
vi.mock("@/components/ShareCardModal", () => ({
default: () => null,
}));
vi.mock("@/components/ContractCompletionModal", () => ({
default: () => null,
}));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
import BlindboxCodePage from "./page";
const toastCtx: ToastContextValue = { show: vi.fn() };
function renderPage() {
return render(
React.createElement(
ToastContext.Provider,
{ value: toastCtx },
React.createElement(BlindboxCodePage),
),
);
}
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
room: {
id: "bb-1",
code: "ABC123",
name: "周末房间",
creatorId: "u1",
city: null,
lat: null,
lng: null,
ideaCount: 3,
memberCount: 2,
drawnCount: 0,
},
ideas: [],
members: [
{ id: "u1", username: "test", avatar: "🐱" },
],
myIdeas: [],
drawnHistory: [],
pendingContracts: [],
}),
});
});
describe("BlindboxCodePage", () => {
it("loads room data", async () => {
renderPage();
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const fetchCall = mockFetch.mock.calls.find(
(c: string[]) => typeof c[0] === "string" && c[0].includes("/api/blindbox/room/ABC123"),
);
expect(fetchCall).toBeDefined();
});
it("fetches room data and loads user ideas", async () => {
renderPage();
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const urls = mockFetch.mock.calls.map((c: string[]) => c[0]);
expect(urls.some((u: string) => u.includes("blindbox/room/ABC123"))).toBe(true);
});
});
+164 -673
View File
@@ -1,505 +1,108 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import { motion, AnimatePresence, useAnimation } from "framer-motion";
import { useState, useEffect, useRef } from "react";
import { useParams } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowLeft,
Send,
Loader2,
Package,
Flame,
Users,
Share2,
LogIn,
Copy,
Trash2,
LogOut,
MapPin,
Calendar,
Sparkles,
ClipboardCheck,
ChevronRight,
} from "lucide-react";
import confetti from "canvas-confetti";
import { getCachedProfile, isRegistered } from "@/lib/userId";
import ShareCardModal from "@/components/ShareCardModal";
import Button from "@/components/Button";
import BlindboxMyIdeas, { type MyIdea } from "@/components/BlindboxMyIdeas";
import BlindboxDrawnHistory, { type DrawnIdea } from "@/components/BlindboxDrawnHistory";
import BlindboxMyIdeas from "@/components/BlindboxMyIdeas";
import BlindboxDrawnHistory from "@/components/BlindboxDrawnHistory";
import WeekendTimeSelector from "@/components/WeekendTimeSelector";
import BlindboxPlan from "@/components/BlindboxPlan";
import ContractCompletionModal, { type PendingContract } from "@/components/ContractCompletionModal";
import BlindboxPoolPhase from "@/components/BlindboxPoolPhase";
import BlindboxRevealPhase from "@/components/BlindboxRevealPhase";
import ContractCompletionModal from "@/components/ContractCompletionModal";
import { useToast } from "@/hooks/useToast";
import { useShare } from "@/hooks/useShare";
import { BlindboxRoomSkeleton } from "@/components/Skeleton";
import type { UserProfile, WeekendPlanData } from "@/types";
interface RoomInfo {
id: string;
code: string;
name: string;
creatorId: string;
city: string | null;
lat: number | null;
lng: number | null;
poolCount: number;
members: { id: string; username: string; avatar: string }[];
}
import { useBlindboxRoom } from "@/hooks/useBlindboxRoom";
import { useBlindboxIdeas } from "@/hooks/useBlindboxIdeas";
import { useBlindboxPlan } from "@/hooks/useBlindboxPlan";
import { useBlindboxDraw } from "@/hooks/useBlindboxDraw";
type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal";
export default function BlindboxRoomPage() {
const { code } = useParams<{ code: string }>();
const router = useRouter();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [room, setRoom] = useState<RoomInfo | null>(null);
const [isMember, setIsMember] = useState(false);
const [joiningRoom, setJoiningRoom] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [input, setInput] = useState("");
const [submitting, setSubmitting] = useState(false);
const [poolCount, setPoolCount] = useState(0);
const [myIdeas, setMyIdeas] = useState<MyIdea[]>([]);
const [drawnHistory, setDrawnHistory] = useState<DrawnIdea[]>([]);
const [phase, setPhase] = useState<Phase>("pool");
const [revealedIdea, setRevealedIdea] = useState<DrawnIdea | null>(null);
const [submitFlash, setSubmitFlash] = useState(false);
const [error, setError] = useState("");
const [showInvite, setShowInvite] = useState(false);
const [showShareCard, setShowShareCard] = useState(false);
const toast = useToast();
const [confirmLeave, setConfirmLeave] = useState(false);
const [leaving, setLeaving] = useState(false);
const [locating, setLocating] = useState(false);
const [planId, setPlanId] = useState<string | null>(null);
const [planDays, setPlanDays] = useState<WeekendPlanData[]>([]);
const [planAccepted, setPlanAccepted] = useState(false);
const [generating, setGenerating] = useState(false);
const [showPlanShareCard, setShowPlanShareCard] = useState(false);
const [activeContract, setActiveContract] = useState<{
id: string;
days: WeekendPlanData[];
endTime: string | null;
} | null>(null);
const [pendingContracts, setPendingContracts] = useState<PendingContract[]>([]);
const boxControls = useAnimation();
const inputRef = useRef<HTMLInputElement>(null);
const [phase, setPhase] = useState<Phase>("pool");
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const confettiAliveRef = useRef(false);
useEffect(() => {
const timers = timersRef.current;
return () => {
timersRef.current.forEach(clearTimeout);
confettiAliveRef.current = false;
timers.forEach(clearTimeout);
timers.length = 0;
};
}, []);
// Hook: Room
const {
profile, room, isMember, joiningRoom, pageLoading, locating,
confirmLeave, leaving, showInvite, setShowInvite, isCreator,
handleJoinRoom, handleSetLocation, handleLeaveOrDelete,
handleCopyCode, handleShareRoom, handleBackToLobby,
} = useBlindboxRoom(code);
// Hook: Ideas
const {
input, setInput, submitting, suggestions, suggestionsLoading,
suggestionsSource, poolCount, setPoolCount, myIdeas, drawnHistory,
setDrawnHistory, submitFlash, error, setError, inputRef,
fetchIdeas, fetchSuggestions, refreshSuggestions,
handleSubmit, handleEditIdea, handleDeleteIdea,
} = useBlindboxIdeas(room, profile);
// Hook: Draw (needs fireConfetti for plan too)
const {
revealedIdea, showShareCard, setShowShareCard,
boxControls: drawBoxControls,
fireConfetti, handleDraw, handleContinue,
} = useBlindboxDraw(room, profile, poolCount, setPoolCount, setDrawnHistory, setError, setPhase);
// Hook: Plan
const {
planDays, planAccepted, generating, planStatusMessages,
showPlanShareCard, setShowPlanShareCard, activeContract,
pendingContracts, planLogRef,
fetchAcceptedPlan, handleGeneratePlan, handlePlanDaysChange,
handleRefine, handleAcceptPlan, handleRegenerate,
showActiveContract, clearPendingContracts,
} = useBlindboxPlan(room, profile, phase, setPhase, fireConfetti);
// Fetch data when member joins — run once when isMember+room are ready
const dataFetchedRef = useRef(false);
useEffect(() => {
if (!isRegistered()) {
router.replace("/blindbox");
return;
}
setProfile(getCachedProfile());
}, [router]);
const fetchRoom = useCallback(async () => {
if (!code) return;
try {
const res = await fetch(`/api/blindbox/room/${code}`);
if (!res.ok) {
router.replace("/blindbox");
return;
}
const data: RoomInfo = await res.json();
setRoom(data);
const p = getCachedProfile();
const memberCheck = data.members.some((m) => m.id === p?.id);
setIsMember(memberCheck);
setPoolCount(data.poolCount);
} catch {
router.replace("/blindbox");
} finally {
setPageLoading(false);
}
}, [code, router]);
useEffect(() => {
fetchRoom();
}, [fetchRoom]);
const fetchIdeas = useCallback(async () => {
const p = getCachedProfile();
if (!room || !p) return;
try {
const res = await fetch(`/api/blindbox?roomId=${room.id}&userId=${p.id}`);
if (res.ok) {
const data = await res.json();
setPoolCount(data.poolCount ?? 0);
setMyIdeas(data.myIdeas ?? []);
setDrawnHistory(data.drawn ?? []);
}
} catch { /* ignore */ }
}, [room]);
const fetchAcceptedPlan = useCallback(async () => {
const p = getCachedProfile();
if (!room || !p) return;
try {
const res = await fetch(`/api/blindbox/plan?mode=latest&roomId=${room.id}&userId=${p.id}`);
if (!res.ok) return;
const data = await res.json();
if (data.plan) {
setActiveContract({
id: data.plan.id,
days: data.plan.days,
endTime: data.plan.endTime ?? null,
});
}
} catch { /* ignore */ }
}, [room]);
useEffect(() => {
if (isMember && room) {
if (isMember && room && !dataFetchedRef.current) {
dataFetchedRef.current = true;
fetchIdeas();
fetchAcceptedPlan();
fetchSuggestions();
}
}, [isMember, room, fetchIdeas, fetchAcceptedPlan]);
// Check for expired contracts on load
useEffect(() => {
const p = getCachedProfile();
if (!isMember || !p) return;
(async () => {
try {
const res = await fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`);
if (!res.ok) return;
const data = await res.json();
if (data.pending?.length) setPendingContracts(data.pending);
} catch { /* ignore */ }
})();
}, [isMember]);
// Browser notification timer for active contract
useEffect(() => {
if (!activeContract?.endTime) return;
const end = new Date(activeContract.endTime).getTime();
const now = Date.now();
const ms = end - now;
if (ms <= 0) return;
if (typeof Notification !== "undefined" && Notification.permission === "default") {
Notification.requestPermission();
}
const timer = setTimeout(() => {
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
const n = new Notification("周末契约到期", {
body: "你的周末契约已结束,完成了吗?",
icon: "/icon-192x192.png",
});
n.onclick = () => { window.focus(); n.close(); };
}
// Refresh pending contracts
const p = getCachedProfile();
if (p) {
fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`)
.then((r) => r.json())
.then((d) => { if (d.pending?.length) setPendingContracts(d.pending); })
.catch(() => {});
}
}, ms);
return () => clearTimeout(timer);
}, [activeContract?.endTime]);
}, [isMember, room, fetchIdeas, fetchAcceptedPlan, fetchSuggestions]);
// Focus input when member
useEffect(() => {
if (isMember && inputRef.current) {
const t = setTimeout(() => inputRef.current?.focus(), 300);
timersRef.current.push(t);
}
}, [isMember]);
const handleJoinRoom = async () => {
if (joiningRoom || !profile || !room) return;
setJoiningRoom(true);
try {
const res = await fetch("/api/blindbox/room/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: profile.id, code }),
});
if (res.ok) {
setIsMember(true);
fetchRoom();
}
} catch { /* ignore */ }
finally { setJoiningRoom(false); }
};
const handleSetLocation = useCallback(async () => {
if (locating || !profile || !room) return;
setLocating(true);
try {
const pos = await new Promise<GeolocationPosition>((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }),
);
const { latitude: lat, longitude: lng } = pos.coords;
const regeoRes = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
const regeo = regeoRes.ok ? await regeoRes.json() : {};
const cityName = regeo.name || "未知位置";
const patchRes = await fetch(`/api/blindbox/room/${room.code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: profile.id, city: cityName, lat, lng }),
});
if (!patchRes.ok) throw new Error("保存位置失败");
setRoom((prev) => prev ? { ...prev, city: cityName, lat, lng } : prev);
toast.show("位置已设置");
} catch {
toast.show("获取位置失败,请允许定位权限");
} finally {
setLocating(false);
}
}, [locating, profile, room, toast]);
const handleGeneratePlan = useCallback(async (timeConfig: { date: string; startHour: number; endHour: number }) => {
if (generating || !profile || !room) return;
setGenerating(true);
setPhase("planning");
setError("");
try {
const res = await fetch("/api/blindbox/plan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: room.id,
userId: profile.id,
availableTime: timeConfig,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "生成失败");
}
const data = await res.json();
setPlanId(data.id);
setPlanDays(data.days);
setPlanAccepted(false);
setPhase("plan_reveal");
fireConfetti();
} catch (e) {
setError(e instanceof Error ? e.message : "生成计划失败");
setPhase("pool");
} finally {
setGenerating(false);
}
}, [generating, profile, room]);
const handleSubmit = async () => {
const text = input.trim();
if (!text || submitting || !profile || !room) return;
setSubmitting(true);
setError("");
try {
const res = await fetch("/api/blindbox", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId: room.id, userId: profile.id, content: text }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "提交失败");
}
const data = await res.json();
setInput("");
setPoolCount((c) => c + 1);
setMyIdeas((prev) => [{
id: data.id,
content: text,
createdAt: new Date().toISOString(),
...data.tags && {
category: data.tags.category,
timeSlot: data.tags.timeSlot,
estimatedMinutes: data.tags.estimatedMinutes,
outdoor: data.tags.outdoor,
searchQuery: data.tags.searchQuery,
searchType: data.tags.searchType,
},
}, ...prev]);
setSubmitFlash(true);
timersRef.current.push(setTimeout(() => setSubmitFlash(false), 600));
boxControls.start({
scale: [1, 1.08, 1],
rotate: [0, -3, 3, 0],
transition: { duration: 0.5 },
});
} catch (e) {
setError(e instanceof Error ? e.message : "提交失败");
} finally {
setSubmitting(false);
}
};
const handleEditIdea = useCallback(async (ideaId: string, newContent: string) => {
if (!profile) return;
const trimmed = newContent.trim();
if (!trimmed || trimmed.length > 200) return;
try {
const res = await fetch("/api/blindbox", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ideaId, userId: profile.id, content: trimmed }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "编辑失败");
}
const data = await res.json();
setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? {
...i,
content: trimmed,
...data.tags && {
category: data.tags.category,
timeSlot: data.tags.timeSlot,
estimatedMinutes: data.tags.estimatedMinutes,
outdoor: data.tags.outdoor,
searchQuery: data.tags.searchQuery,
searchType: data.tags.searchType,
},
} : i)));
} catch (e) {
toast.show(e instanceof Error ? e.message : "编辑失败");
}
}, [profile, toast]);
const handleDeleteIdea = useCallback(async (ideaId: string) => {
if (!profile) return;
try {
const res = await fetch("/api/blindbox", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ideaId, userId: profile.id }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "删除失败");
}
setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId));
setPoolCount((c) => Math.max(0, c - 1));
} catch (e) {
toast.show(e instanceof Error ? e.message : "删除失败");
}
}, [profile]);
const handleDraw = async () => {
if (poolCount === 0 || !profile || !room) {
setError("盒子是空的,先往里面塞点想法吧!");
return;
}
setPhase("shaking");
setError("");
await boxControls.start({
rotate: [0, -8, 8, -10, 10, -12, 12, -8, 8, -4, 4, 0],
scale: [1, 1.05, 0.95, 1.08, 0.92, 1.1, 0.9, 1.05, 0.95, 1],
transition: { duration: 2.5, ease: "easeInOut" },
});
try {
const res = await fetch("/api/blindbox/draw", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId: room.id, userId: profile.id }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "抽取失败");
}
const idea = await res.json();
setRevealedIdea(idea);
setPhase("reveal");
setPoolCount((c) => Math.max(0, c - 1));
setDrawnHistory((prev) => [idea, ...prev]);
fireConfetti();
} catch (e) {
setError(e instanceof Error ? e.message : "抽取失败");
setPhase("pool");
}
};
const fireConfetti = () => {
const colors = ["#a855f7", "#6366f1", "#ec4899", "#f59e0b", "#10b981"];
confetti({ particleCount: 100, spread: 120, origin: { y: 0.4 }, colors, startVelocity: 45, ticks: 250 });
confettiAliveRef.current = true;
const end = Date.now() + 3000;
const frame = () => {
if (Date.now() > end || !confettiAliveRef.current) return;
confetti({ particleCount: 3, angle: 60, spread: 55, origin: { x: 0, y: 0.6 }, colors, startVelocity: 35, ticks: 150 });
confetti({ particleCount: 3, angle: 120, spread: 55, origin: { x: 1, y: 0.6 }, colors, startVelocity: 35, ticks: 150 });
requestAnimationFrame(frame);
};
timersRef.current.push(setTimeout(frame, 200));
};
const { share, copyToClipboard } = useShare();
const handleCopyCode = useCallback(
() => room ? copyToClipboard(room.code, "房间号已复制") : undefined,
[room, copyToClipboard],
);
const handleShare = useCallback(() => {
if (!room) return;
const url = typeof window !== "undefined" ? `${window.location.origin}/blindbox/${room.code}` : "";
share(
{ title: `周末契约 · ${room.name}`, text: `来和我一起玩周末盲盒吧!房间号:${room.code}`, url },
handleCopyCode,
);
}, [room, share, handleCopyCode]);
const isCreator = profile?.id === room?.creatorId;
const handleLeaveOrDelete = async () => {
if (!confirmLeave) {
setConfirmLeave(true);
timersRef.current.push(setTimeout(() => setConfirmLeave(false), 3000));
return;
}
if (leaving || !profile || !room) return;
setLeaving(true);
try {
const res = await fetch(`/api/blindbox/room/${room.code}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: profile.id }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "操作失败");
}
router.replace("/blindbox");
} catch (e) {
toast.show(e instanceof Error ? e.message : "操作失败");
setConfirmLeave(false);
} finally {
setLeaving(false);
}
};
if (pageLoading) {
return <BlindboxRoomSkeleton />;
}
}, [isMember, inputRef]);
if (pageLoading) return <BlindboxRoomSkeleton />;
if (!room) return null;
return (
@@ -510,7 +113,7 @@ export default function BlindboxRoomPage() {
{/* Header */}
<div className="flex w-full max-w-sm items-center gap-3">
<button
onClick={() => router.push("/blindbox")}
onClick={handleBackToLobby}
aria-label="返回"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface ring-1 ring-border transition-colors active:bg-elevated"
>
@@ -525,17 +128,12 @@ export default function BlindboxRoomPage() {
disabled={locating}
className="flex items-center gap-0.5 text-[10px] font-medium text-purple-400/70 transition-colors active:text-purple-400"
>
{locating ? (
<Loader2 size={9} className="animate-spin" />
) : (
<MapPin size={9} />
)}
{locating ? <Loader2 size={9} className="animate-spin" /> : <MapPin size={9} />}
{room.city || "设置位置"}
</button>
</div>
</div>
{/* Members */}
<div className="flex -space-x-1.5">
{room.members.slice(0, 4).map((m) => (
<div
@@ -568,12 +166,7 @@ export default function BlindboxRoomPage() {
className="mt-3 flex w-full max-w-sm items-center gap-2 rounded-xl bg-purple-600/10 px-3 py-2 ring-1 ring-purple-500/20 transition-colors active:bg-purple-600/20"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
onClick={() => {
setPlanId(activeContract.id);
setPlanDays(activeContract.days);
setPlanAccepted(true);
setPhase("plan_reveal");
}}
onClick={showActiveContract}
>
<ClipboardCheck size={14} className="shrink-0 text-purple-400" />
<span className="text-xs font-medium text-purple-300"></span>
@@ -606,7 +199,7 @@ export default function BlindboxRoomPage() {
<Copy size={12} />
</button>
<button
onClick={handleShare}
onClick={handleShareRoom}
className="flex h-8 items-center gap-1 rounded-lg bg-purple-600 px-3 text-xs font-medium text-white transition-colors active:bg-purple-500"
>
<Share2 size={12} />
@@ -637,11 +230,11 @@ export default function BlindboxRoomPage() {
</motion.div>
) : (
<>
{/* Blind Box Visual — hidden during plan phases */}
{/* Blind Box Visual */}
{phase !== "planning" && phase !== "plan_reveal" && (
<div className="mt-8 flex flex-col items-center">
<motion.div
animate={boxControls}
animate={drawBoxControls}
className="relative flex h-36 w-36 items-center justify-center"
>
<div className="absolute inset-0 rounded-3xl bg-linear-to-br from-purple-600/20 to-indigo-600/20 blur-xl" />
@@ -678,78 +271,30 @@ export default function BlindboxRoomPage() {
</div>
)}
{/* Pool / Shaking / Reveal phases */}
{/* Phase content */}
<AnimatePresence mode="wait">
{phase === "pool" && (
<motion.div
key="pool"
className="mt-8 flex w-full max-w-sm flex-col items-center gap-5"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<div className="flex w-full gap-2">
<input
ref={inputRef}
type="text"
placeholder="塞入一个疯狂的周末想法..."
value={input}
onChange={(e) => { setInput(e.target.value); setError(""); }}
onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }}
maxLength={200}
disabled={submitting}
className="h-12 flex-1 rounded-xl border-none bg-surface px-4 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600 disabled:opacity-50"
/>
<button
onClick={handleSubmit}
disabled={!input.trim() || submitting}
aria-label="提交想法"
className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600 text-white transition-colors hover:bg-purple-500 disabled:opacity-30"
>
{submitting ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
</div>
<div className="flex w-full gap-2">
<motion.button
onClick={handleDraw}
disabled={poolCount === 0}
className="relative flex h-14 flex-1 items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-red-600 to-rose-500 text-sm font-black text-white shadow-lg shadow-red-900/40 transition-shadow hover:shadow-xl hover:shadow-red-900/50 disabled:opacity-40 disabled:shadow-none"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/10 to-transparent -translate-x-full animate-[shimmer_3s_infinite]" />
<Flame size={18} />
</motion.button>
<motion.button
onClick={() => {
if (!room?.city) {
toast.show("请先点击房间名下方设置位置");
return;
}
setPhase("time_select");
}}
disabled={poolCount < 2}
className="relative flex h-14 flex-1 items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-purple-600 to-indigo-600 text-sm font-black text-white shadow-lg shadow-purple-900/40 transition-shadow hover:shadow-xl hover:shadow-purple-900/50 disabled:opacity-40 disabled:shadow-none"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<Calendar size={18} />
</motion.button>
</div>
{error && (
<motion.p
className="text-center text-xs font-medium text-rose-400"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.p>
)}
</motion.div>
<BlindboxPoolPhase
input={input}
setInput={setInput}
submitting={submitting}
suggestions={suggestions}
suggestionsLoading={suggestionsLoading}
suggestionsSource={suggestionsSource}
poolCount={poolCount}
error={error}
setError={setError}
inputRef={inputRef}
planDays={planDays}
planAccepted={planAccepted}
hasLocation={!!room.city}
onSubmit={handleSubmit}
onDraw={handleDraw}
onPlanStart={() => setPhase("time_select")}
onRefreshSuggestions={refreshSuggestions}
onShowPlan={() => setPhase("plan_reveal")}
onLocationMissing={() => toast.show("请先点击房间名下方设置位置")}
/>
)}
{phase === "shaking" && (
@@ -777,96 +322,52 @@ export default function BlindboxRoomPage() {
)}
{phase === "reveal" && revealedIdea && (
<motion.div
key="reveal"
className="mt-8 flex w-full max-w-sm flex-col items-center gap-5"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", damping: 15, stiffness: 200 }}
>
<div className="relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-purple-900 via-indigo-900 to-purple-950 p-6 shadow-2xl shadow-purple-900/50 ring-1 ring-purple-600/30">
<div className="absolute left-3 top-3 h-6 w-6 border-l-2 border-t-2 border-purple-400/30 rounded-tl-sm" />
<div className="absolute right-3 top-3 h-6 w-6 border-r-2 border-t-2 border-purple-400/30 rounded-tr-sm" />
<div className="absolute bottom-3 left-3 h-6 w-6 border-b-2 border-l-2 border-purple-400/30 rounded-bl-sm" />
<div className="absolute bottom-3 right-3 h-6 w-6 border-b-2 border-r-2 border-purple-400/30 rounded-br-sm" />
<div className="relative z-10 text-center">
<p className="text-xs font-bold tracking-[0.3em] text-purple-400/70">
</p>
<motion.p
className="mt-4 text-xl font-black leading-relaxed text-white"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
{revealedIdea.content}
</motion.p>
<div className="mx-auto mt-4 h-px w-16 bg-linear-to-r from-transparent via-purple-400/50 to-transparent" />
{/* Attribution */}
<div className="mt-3 flex items-center justify-center gap-2 text-[11px] text-purple-400/50">
{revealedIdea.user && (
<span>
{revealedIdea.user.avatar} {revealedIdea.user.username}
</span>
)}
{revealedIdea.drawnBy && (
<>
<span>·</span>
<span>
{revealedIdea.drawnBy.avatar} {revealedIdea.drawnBy.username}
</span>
</>
)}
</div>
<p className="mt-2 text-[10px] font-medium text-purple-400/40">
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button
onClick={() => setShowShareCard(true)}
variant="purple"
shape="pill"
icon={<Share2 size={14} />}
>
</Button>
<Button
onClick={() => { setPhase("pool"); setRevealedIdea(null); setShowShareCard(false); }}
variant="secondary"
shape="pill"
>
</Button>
</div>
</motion.div>
<BlindboxRevealPhase
idea={revealedIdea}
onShare={() => setShowShareCard(true)}
onContinue={handleContinue}
/>
)}
{phase === "planning" && (
<motion.div
key="planning"
className="mt-8 flex flex-col items-center gap-4"
className="mt-8 flex flex-col items-center gap-4 w-full px-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
className="relative flex h-20 w-20 items-center justify-center"
className="relative flex h-16 w-16 items-center justify-center"
animate={{ rotate: [0, 360] }}
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
>
<div className="absolute inset-0 rounded-full bg-purple-600/15 blur-lg" />
<Sparkles size={28} className="relative text-purple-400" />
<Sparkles size={24} className="relative text-purple-400" />
</motion.div>
<p className="text-sm font-bold text-purple-300 animate-pulse">
AI ...
</p>
<p className="text-[11px] text-dim"> · 线 · </p>
<div className="w-full max-w-sm rounded-xl bg-surface/60 ring-1 ring-border/60 overflow-hidden">
<div
ref={planLogRef}
className="h-40 overflow-y-auto scrollbar-none p-3 flex flex-col gap-1.5"
>
{planStatusMessages.map((msg, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className={`flex items-center gap-2 text-xs ${i === planStatusMessages.length - 1 ? "text-purple-300 font-medium" : "text-dim"}`}
>
{i === planStatusMessages.length - 1 ? (
<Loader2 size={11} className="shrink-0 animate-spin text-purple-400" />
) : (
<span className="shrink-0 text-[10px] text-purple-500"></span>
)}
{msg}
</motion.div>
))}
</div>
</div>
</motion.div>
)}
@@ -882,42 +383,19 @@ export default function BlindboxRoomPage() {
days={planDays}
accepted={planAccepted}
regenerating={generating}
onDaysChange={handlePlanDaysChange}
onRefine={handleRefine}
location={room.lng != null && room.lat != null ? `${room.lng},${room.lat}` : undefined}
startLocationLabel={room.address ?? room.city ?? undefined}
onAccept={async () => {
setPlanAccepted(true);
fireConfetti();
if (planId && profile) {
try {
const res = await fetch("/api/blindbox/plan", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planId, userId: profile.id, action: "accept" }),
});
const data = await res.json();
setActiveContract({
id: planId,
days: planDays,
endTime: data.endTime ?? null,
});
} catch { /* best-effort */ }
}
toast.show("契约已接受!");
await handleAcceptPlan();
timersRef.current.push(setTimeout(() => {
setPhase("pool");
setPlanId(null);
setPlanDays([]);
setPlanAccepted(false);
}, 1500));
}}
onRegenerate={() => {
setPhase("time_select");
}}
onRegenerate={handleRegenerate}
onShare={() => setShowPlanShareCard(true)}
onBack={() => {
setPhase("pool");
setPlanId(null);
setPlanDays([]);
setPlanAccepted(false);
}}
onBack={() => setPhase("pool")}
/>
</motion.div>
)}
@@ -974,34 +452,50 @@ export default function BlindboxRoomPage() {
/>
)}
{/* Leave / Delete — hidden during plan view */}
{/* Leave / Back */}
{isMember && room && phase !== "plan_reveal" && phase !== "planning" && (
<motion.div
className="mt-12 w-full max-w-sm"
className="mt-12 flex w-full max-w-sm flex-col items-center gap-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
>
<button
onClick={handleLeaveOrDelete}
disabled={leaving}
className={`flex w-full items-center justify-center gap-2 rounded-xl py-2.5 text-xs font-medium transition-colors ${
confirmLeave
? "bg-rose-600/15 text-rose-400 ring-1 ring-rose-500/30"
: "text-muted hover:text-rose-400/80"
}`}
>
{leaving ? (
<Loader2 size={13} className="animate-spin" />
) : isCreator ? (
<Trash2 size={13} />
) : (
<LogOut size={13} />
)}
{confirmLeave
? isCreator ? "确认删除房间?所有想法将被清除" : "确认退出房间?"
: isCreator ? "删除房间" : "退出房间"}
</button>
{isCreator ? (
<>
<button
onClick={handleBackToLobby}
className="flex w-full items-center justify-center gap-2 rounded-xl py-2.5 text-xs font-medium text-muted transition-colors hover:text-foreground active:bg-elevated"
>
<ArrowLeft size={13} />
</button>
<button
onClick={handleLeaveOrDelete}
disabled={leaving}
className={`flex w-full items-center justify-center gap-2 rounded-xl py-2 text-xs font-medium transition-colors ${
confirmLeave
? "bg-rose-600/15 text-rose-400 ring-1 ring-rose-500/30"
: "text-dim hover:text-rose-400/80"
}`}
>
{leaving ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
{confirmLeave ? "确认删除房间?所有想法将被清除" : "删除房间"}
</button>
</>
) : (
<button
onClick={handleLeaveOrDelete}
disabled={leaving}
className={`flex w-full items-center justify-center gap-2 rounded-xl py-2.5 text-xs font-medium transition-colors ${
confirmLeave
? "bg-rose-600/15 text-rose-400 ring-1 ring-rose-500/30"
: "text-muted hover:text-rose-400/80"
}`}
>
{leaving ? <Loader2 size={13} className="animate-spin" /> : <LogOut size={13} />}
{confirmLeave ? "确认退出房间?" : "退出房间"}
</button>
)}
</motion.div>
)}
@@ -1012,10 +506,7 @@ export default function BlindboxRoomPage() {
<ContractCompletionModal
contracts={pendingContracts}
userId={profile.id}
onDone={() => {
setPendingContracts([]);
setActiveContract(null);
}}
onDone={clearPendingContracts}
/>
)}
</div>
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { ToastContext, type ToastContextValue } from "@/hooks/useToast";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
back: vi.fn(),
}),
}));
vi.mock("@/lib/userId", () => ({
getCachedProfile: vi.fn().mockReturnValue({ id: "u1", username: "test", avatar: "🐱" }),
isRegistered: vi.fn().mockReturnValue(true),
}));
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ rooms: [] }),
});
vi.stubGlobal("fetch", mockFetch);
import BlindboxPage from "./page";
const toastCtx: ToastContextValue = { show: vi.fn() };
function renderPage() {
return render(
React.createElement(
ToastContext.Provider,
{ value: toastCtx },
React.createElement(BlindboxPage),
),
);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("BlindboxPage", () => {
it("renders page heading", async () => {
renderPage();
expect(await screen.findByText("周末契约")).toBeInTheDocument();
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
});
it("renders back button", async () => {
renderPage();
await screen.findByText("周末契约");
const backBtns = screen.getAllByRole("button");
expect(backBtns.length).toBeGreaterThan(0);
});
it("renders subtitle text", async () => {
renderPage();
expect(await screen.findByText("ADVENTURE ROULETTE")).toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show More