Compare commits
66 Commits
dev
...
00683e51c4
| Author | SHA1 | Date | |
|---|---|---|---|
| 00683e51c4 | |||
| 5b5ecd1b29 | |||
| 8ab8557ef1 | |||
| 91dcb735cd | |||
| 57dd743fc1 | |||
| a61081fca9 | |||
| 6490cd068e | |||
| 303e32f599 | |||
| b117a3694d | |||
| 0b28c94762 | |||
| 35aa7fb6bc | |||
| f892c92608 | |||
| 17cf80bed3 | |||
| cf7a7bd2e0 | |||
| b28c61ea3a | |||
| c4c1cb400b | |||
| fe3016d710 | |||
| 482307f2f4 | |||
| d91ea8c1e8 | |||
| 3cd4c26d3c | |||
| 22610f0b59 | |||
| 532d8ff7ad | |||
| 4a5ed3b25a | |||
| 325b7b5742 | |||
| 41ac21ea12 | |||
| 4cd593bc30 | |||
| 45dbac1935 | |||
| 52b87abee3 | |||
| 673dc1177e | |||
| 67fdf7427a | |||
| 724be69c76 | |||
| 5a6d457a87 | |||
| 486193c823 | |||
| 8b4ab415fd | |||
| f3d8a58603 | |||
| 4f4220652e | |||
| 6bb0e65d4c | |||
| ce76980fe5 | |||
| 99120a7042 | |||
| e5a255a49e | |||
| 7b6ce22f63 | |||
| 04a45c4894 | |||
| 4e6a3e007c | |||
| df2e373beb | |||
| b78063739b | |||
| 9d891fb702 | |||
| ea49ce6081 | |||
| 7fd1005e03 | |||
| 93499867d5 | |||
| bc2b0e40c6 | |||
| dfb7f464a0 | |||
| fe9a52e2e4 | |||
| 0e9d4ae43e | |||
| 3ccd1262f9 | |||
| 11eeec868e | |||
| 827fb761bd | |||
| bf806f0891 | |||
| 3e7bb51618 | |||
| 039887547d | |||
| 1e39c72a63 | |||
| 2d49744dd0 | |||
| 9aee4f0e9b | |||
| 61ef54b2bd | |||
| 76349f0dcf | |||
| 4073be9066 | |||
| 3409456ca5 |
@@ -12,6 +12,8 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/playwright-report
|
||||
/test-results
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 | 邀请页信息补全 | 邀请转化率提升 | 低 |
|
||||
| — | 网络断开提示 | 减少数据丢失投诉 | 中 |
|
||||
| — | 滑卡触觉反馈 | 移动端操作满足感 | 低 |
|
||||
|
||||
---
|
||||
|
||||
> 核心原则:**先让更多人用起来,再让用起来的人留下来,最后让留下来的人带新人。**
|
||||
@@ -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 统一)。
|
||||
|
||||
## 交付说明
|
||||
- 本文档已从“静态审查结论”演进为“修复跟踪文档”,会随每轮修复持续更新状态。
|
||||
- 现阶段建议继续按“问题分组 + 回归测试同步补齐”的节奏推进。
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# 项目代码质量全景分析 & 重构计划
|
||||
|
||||
> 生成时间: 2026-03-02 | 分析工具: Claude Code 7-agent 并行审查
|
||||
|
||||
## 模块评分总览
|
||||
|
||||
| # | 模块 | 评分 | 重构优先级 | 核心问题 |
|
||||
|---|------|------|-----------|----------|
|
||||
| 1 | 认证 & 用户 API | 7.5 | 高 | 无真正鉴权机制,userId 可伪造 |
|
||||
| 2 | 房间投票 API | 7.0 | 中 | atomicUpdateRoom 并发 lost update |
|
||||
| 3 | 盲盒系统 API | 7.0 | 中 | AI 错误静默吞掉,plan/route.ts 臃肿 |
|
||||
| 4 | 位置服务 API | 6.5 | 中 | 高德 API 调用代码 4 处重复,无超时/缓存 |
|
||||
| 5 | 前端页面 | 5.5 | 高 | blindbox/[code] 1300行 37个useState |
|
||||
| 6 | 组件 & Hooks | 6.5 | 高 | ShareCard 三兄弟 60% 重复,BlindboxPlan 742行 |
|
||||
| 7 | 基础设施 & Lib | 6.5 | 高 | Room JSON blob 模型、前后端代码边界模糊 |
|
||||
|
||||
**全局平均: 6.6 / 10**
|
||||
|
||||
---
|
||||
|
||||
## 跨模块共性问题 TOP 5
|
||||
|
||||
### 1. 无真正的认证机制 (影响: 全局)
|
||||
所有 API 的 userId 由客户端 localStorage 传入,任何人可伪造身份操作他人数据。登录接口签发了用户信息但没有 JWT/Cookie,形同虚设。
|
||||
|
||||
### 2. AI/外部 API 错误被静默吞掉 (影响: 盲盒、位置、Lib)
|
||||
ai.ts 所有函数的 catch 都是 return null,amap 调用无超时,blindboxPlanGen.ts 多处空 catch。生产环境问题排查极其困难。
|
||||
|
||||
### 3. 巨型组件/页面 (影响: 前端)
|
||||
- blindbox/[code]/page.tsx: 1300行, 37个useState, 12个useEffect
|
||||
- BlindboxPlan.tsx: 742行
|
||||
- blindbox/page.tsx: 658行, 18个useState
|
||||
- MatchResult.tsx: 513行
|
||||
|
||||
### 4. 代码重复 (影响: 组件、位置、盲盒)
|
||||
- ShareCard 三个文件 ~1024行,重复率 ~60%
|
||||
- 高德 transit API 调用在 4处 复制粘贴
|
||||
- 标签更新映射代码 3处 重复
|
||||
- generateRoomCode 函数 2处 相同实现
|
||||
|
||||
### 5. Room 数据模型: JSON Blob 反模式 (影响: 房间、Lib)
|
||||
Room.data 用一个 JSON 字符串存储全部状态,无法利用数据库约束、索引和关联查询。同项目的 BlindBoxRoom 系列已证明团队有能力做好关系化建模。
|
||||
|
||||
---
|
||||
|
||||
## 重构路线图
|
||||
|
||||
### P0 -- 立即修复 (安全/数据完整性)
|
||||
|
||||
- [ ] **实现 JWT/httpOnly Cookie 认证链路** (2-3天)
|
||||
- 登录签发 JWT,设置 httpOnly cookie
|
||||
- 新增 getAuthUserId(req) 从 cookie 中提取用户
|
||||
- 所有 /api/user/*, /api/blindbox/*, /api/room/* 改用服务端鉴权
|
||||
- 前端移除 userId 参数传递,改为 cookie 自动携带
|
||||
|
||||
- [ ] **修复 atomicUpdateRoom 并发安全** (0.5天)
|
||||
- SQLite 下使用 IMMEDIATE 事务或应用层锁
|
||||
- 如迁移到 PostgreSQL,使用 SELECT ... FOR UPDATE
|
||||
|
||||
- [ ] **所有 catch 块至少加 console.error** (0.5天)
|
||||
- ai.ts: tagIdea, suggestIdeas, generateSchedule, refinePlan, suggestAlternativeItems
|
||||
- blindboxPlanGen.ts: 多处空 catch
|
||||
- 前端组件: BlindboxPlan, MatchResult, SwipeDeck, RestaurantCard 等
|
||||
- API routes: applyTags 的 fire-and-forget
|
||||
|
||||
### P1 -- 短期重构 (代码质量)
|
||||
|
||||
- [ ] 拆分 blindbox/[code]/page.tsx 为 5-6 个子模块 + hooks (2-3天)
|
||||
- [ ] 抽取 ShareCardShell 消除三兄弟重复 (1天)
|
||||
- [ ] 拆分 BlindboxPlan.tsx (1天)
|
||||
- [ ] 完善 amap.ts 为完整 API 客户端 (1天)
|
||||
- [ ] 统一数据获取层 SWR 替代裸 fetch+useState (1-2天)
|
||||
|
||||
### P2 -- 中期优化 (架构改善)
|
||||
|
||||
- [ ] Room JSON blob 拆为关系化模型 (3-5天)
|
||||
- [ ] 引入 zod 做 AI 返回值 + request body 校验 (1-2天)
|
||||
- [ ] blindboxPlanGen.ts 拆为 4 个子模块 (1天)
|
||||
- [ ] ApiError 独立 + validation 纯函数化 (0.5天)
|
||||
- [ ] plan/route.ts PATCH/GET 内部拆分 (0.5天)
|
||||
|
||||
### P3 -- 长期改善
|
||||
|
||||
- [ ] 部分页面引入 Server Component 混合渲染
|
||||
- [ ] 补全 a11y (aria-label、键盘导航)
|
||||
- [ ] SSE 重连策略 (指数退避)
|
||||
- [ ] 文件重命名 (store.ts → roomRepository.ts)
|
||||
|
||||
---
|
||||
|
||||
## 各模块详细分析
|
||||
|
||||
### 模块1: 认证 & 用户 API (7.5/10)
|
||||
|
||||
**亮点:**
|
||||
- 已有 apiHandler 统一错误处理框架
|
||||
- validation.ts 提供输入校验工具
|
||||
- P2002 唯一约束冲突有分层防御
|
||||
|
||||
**问题:**
|
||||
1. [Critical] userId 由客户端传入可伪造,无 JWT/session
|
||||
2. [Critical] 登录成功后无状态维持(不签发 token)
|
||||
3. [Warning] JSON.parse 部分未做防御处理
|
||||
4. [Warning] GET /api/user 无需鉴权即可查任意用户 email
|
||||
5. [Warning] login 接口缺少暴力破解防护
|
||||
6. [Suggestion] 用户序列化逻辑重复
|
||||
7. [Suggestion] achievements 业务逻辑偏重可抽 service
|
||||
|
||||
### 模块2: 房间投票 API (7.0/10)
|
||||
|
||||
**亮点:**
|
||||
- atomicUpdateRoom 原子操作封装
|
||||
- buildRoomStatus 视图层分离
|
||||
- roomEvents 发布/订阅模式
|
||||
- 各 handler 职责单一 (10-30行)
|
||||
|
||||
**问题:**
|
||||
1. [Critical] atomicUpdateRoom 事务隔离级别不足,并发 lost update
|
||||
2. [Critical] Room.data JSON blob 反模式
|
||||
3. [Warning] SSE 连接中被踢用户无实时通知
|
||||
4. [Warning] 房间过期清理机制不可靠 (进程内变量)
|
||||
5. [Warning] getRoomData 中 fire-and-forget delete
|
||||
6. [Warning] buildRoomStatus 每次查 DB 获取 UserProfile,缺缓存
|
||||
7. [Suggestion] 房间 ID 使用 Math.random() 非密码学安全
|
||||
|
||||
### 模块3: 盲盒系统 API (7.0/10)
|
||||
|
||||
**亮点:**
|
||||
- Agent + Legacy 双路径容错设计
|
||||
- requireMembership 公共权限检查
|
||||
- IdeaTags 多维标签系统设计合理
|
||||
- 路由数量合理,职责清晰
|
||||
|
||||
**问题:**
|
||||
1. [Critical] applyTags 静默吞掉所有错误
|
||||
2. [Critical] retag 端点串行处理无并发控制
|
||||
3. [Critical] plan/route.ts 227行承担过多职责
|
||||
4. [Warning] 标签更新映射代码 3处 重复
|
||||
5. [Warning] roomId 验证方式不一致
|
||||
6. [Warning] plan/stream 未使用 apiHandler
|
||||
7. [Warning] plan GET 的 pending/history 有 N+1 查询
|
||||
8. [Suggestion] blindboxPlanGen.ts 808行应拆分
|
||||
|
||||
### 模块4: 位置服务 API (6.5/10)
|
||||
|
||||
**亮点:**
|
||||
- 三个 location 端点都有测试覆盖
|
||||
- apiHandler 统一包装
|
||||
|
||||
**问题:**
|
||||
1. [Critical] 高德 transit API 调用在 4处 复制粘贴
|
||||
2. [Critical] fetch 调用缺少超时控制
|
||||
3. [Critical] debug 端点未用 apiHandler 且缺 try-catch
|
||||
4. [Warning] amap.ts 封装过于薄弱 (仅 7行)
|
||||
5. [Warning] 缺少缓存策略
|
||||
6. [Warning] fetch 响应未检查 HTTP 状态码
|
||||
7. [Warning] 输入验证不够严格 (经纬度未校验范围)
|
||||
8. [Suggestion] POI 类型定义散落多文件
|
||||
|
||||
### 模块5: 前端页面 (5.5/10)
|
||||
|
||||
**亮点:**
|
||||
- 路由设计清晰语义明确
|
||||
- Skeleton 骨架屏使用
|
||||
|
||||
**问题:**
|
||||
1. [Critical] blindbox/[code]/page.tsx 1300行 37个useState "上帝组件"
|
||||
2. [Critical] blindbox/page.tsx 658行表单代码重复
|
||||
3. [Critical] profile/page.tsx 521行 18个useState
|
||||
4. [Warning] 数据获取方式不一致 (裸fetch vs SWR)
|
||||
5. [Warning] userId 通过 URL 参数传递
|
||||
6. [Warning] 错误静默吞掉
|
||||
7. [Warning] panic/page.tsx 598行位置搜索逻辑应抽取
|
||||
8. [Suggestion] 所有页面都是 "use client" 未利用 SSR
|
||||
9. [Suggestion] 重复的页面布局模式 (ambient glow + 返回按钮)
|
||||
|
||||
### 模块6: 组件 & Hooks (6.5/10)
|
||||
|
||||
**亮点:**
|
||||
- Hooks 设计整体合理
|
||||
- Toast 系统简洁
|
||||
- useShare 正确处理 AbortError
|
||||
|
||||
**问题:**
|
||||
1. [Critical] ShareCard 三兄弟 ~60% 代码重复
|
||||
2. [Critical] BlindboxPlan.tsx 742行职责过多
|
||||
3. [Warning] 多处错误被静默吞掉
|
||||
4. [Warning] useRoomPolling SSE 重连策略不足
|
||||
5. [Warning] MatchResult.tsx 513行职责偏重
|
||||
6. [Warning] useEffect 依赖项不完整
|
||||
7. [Suggestion] 可访问性(a11y)严重不足
|
||||
8. [Suggestion] ShareCard inline style 缺注释说明原因
|
||||
|
||||
### 模块7: 基础设施 & Lib (6.5/10)
|
||||
|
||||
**亮点:**
|
||||
- 前后端目前无实际交叉引用错误
|
||||
- sceneConfig/avatars 等纯函数设计良好
|
||||
|
||||
**问题:**
|
||||
1. [Critical] Room JSON blob + WeekendPlan/Decision JSON 存储
|
||||
2. [Warning] store.ts 命名严重误导 (服务端代码叫 store)
|
||||
3. [Warning] api.ts 混合两类职责
|
||||
4. [Warning] blindboxPlanGen.ts 808行过大
|
||||
5. [Warning] 两套房间模型 (Room vs BlindBoxRoom) 设计差异大
|
||||
6. [Warning] ai.ts AI 返回值校验不统一
|
||||
7. [Warning] userId.ts 基于 localStorage UUID 无安全性
|
||||
8. [Suggestion] runAgentLoop 硬编码 finalize_plan 工具名
|
||||
9. [Suggestion] roomEvents 进程内存级发布订阅
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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$/);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -11,6 +11,7 @@ const eslintConfig = defineConfig([
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"coverage/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -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?
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 975 KiB |
|
After Width: | Height: | Size: 447 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
@@ -1,5 +1,4 @@
|
||||
import sharp from "sharp";
|
||||
import { mkdirSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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,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("周末");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("缺少必要字段");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||