diff --git a/package.json b/package.json index 29e0ed5..dd29340 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "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": { "@tailwindcss/postcss": "^4", diff --git a/prisma/migrations/20260302010000_room_relational_model/migration.sql b/prisma/migrations/20260302010000_room_relational_model/migration.sql new file mode 100644 index 0000000..eea582d --- /dev/null +++ b/prisma/migrations/20260302010000_room_relational_model/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 63a42de..1eb9d24 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/achievements/AchievementsClient.tsx b/src/app/achievements/AchievementsClient.tsx new file mode 100644 index 0000000..9326f4f --- /dev/null +++ b/src/app/achievements/AchievementsClient.tsx @@ -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).image; + return typeof legacy === "string" ? legacy : ""; +} + +export default function AchievementsPage({ initialUserId }: { initialUserId: string }) { + const router = useRouter(); + const [tab, setTab] = useState("decisions"); + const [userId] = useState(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 ( +
+ {/* Ambient glow */} +
+ + {/* Header */} +
+ +
+ +

成就墙

+
+
+ + {/* Stats */} + + {statCards.map((s) => ( +
+
+ +
+ {loading ? ( + + ) : ( +

{s.value}

+ )} +

{s.label}

+
+ ))} +
+ + {/* Tab switcher */} + + {tabs.map((t) => ( + + ))} + + + {/* Content */} +
+ + {tab === "decisions" && ( + + {loading ? ( + <> + + + + + ) : decisions.length === 0 ? ( + + ) : ( + decisions.map((d) => ( + + {firstImage(d.restaurantData) && ( + + )} +
+

+ {d.restaurantName} +

+
+ + {d.matchType === "unanimous" ? "全员一致" : "最佳匹配"} + + {d.participants} 人参与 + + {new Date(d.createdAt).toLocaleDateString("zh-CN", { + month: "short", + day: "numeric", + })} + +
+
+
+ )) + )} +
+ )} + + {tab === "contracts" && ( + + {loading ? ( + <> + + + + + ) : contracts.length === 0 ? ( + + ) : ( + contracts.map((c) => ( + + )) + )} + + )} +
+
+ +
+
+ ); +} diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index e962a4a..efc2911 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -1,252 +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 { useAchievements } from "@/hooks/useAchievements"; -import type { 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("/"); -function firstImage(r: Restaurant): string { - if (r.images?.length > 0) return r.images[0]; - const legacy = (r as unknown as Record).image; - return typeof legacy === "string" ? legacy : ""; -} - -export default function AchievementsPage() { - const router = useRouter(); - const [tab, setTab] = useState("decisions"); - const [userId, setUserId] = useState(undefined); - - useEffect(() => { - if (!isRegistered()) { - router.replace("/"); - return; - } - const p = getCachedProfile(); - if (p) setUserId(p.id); - }, [router]); - - 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 ( -
- {/* Ambient glow */} -
- - {/* Header */} -
- -
- -

成就墙

-
-
- - {/* Stats */} - - {statCards.map((s) => ( -
-
- -
- {loading ? ( - - ) : ( -

{s.value}

- )} -

{s.label}

-
- ))} -
- - {/* Tab switcher */} - - {tabs.map((t) => ( - - ))} - - - {/* Content */} -
- - {tab === "decisions" && ( - - {loading ? ( - <> - - - - - ) : decisions.length === 0 ? ( - - ) : ( - decisions.map((d) => ( - - {firstImage(d.restaurantData) && ( - - )} -
-

- {d.restaurantName} -

-
- - {d.matchType === "unanimous" ? "全员一致" : "最佳匹配"} - - {d.participants} 人参与 - - {new Date(d.createdAt).toLocaleDateString("zh-CN", { - month: "short", - day: "numeric", - })} - -
-
-
- )) - )} -
- )} - - {tab === "contracts" && ( - - {loading ? ( - <> - - - - - ) : contracts.length === 0 ? ( - - ) : ( - contracts.map((c) => ( - - )) - )} - - )} -
-
- -
-
- ); + let userId: string; + try { + userId = await verifyToken(token); + } catch { + redirect("/"); + } + + return ; } diff --git a/src/app/api/blindbox/draw/route.test.ts b/src/app/api/blindbox/draw/route.test.ts index 372f612..59a842a 100644 --- a/src/app/api/blindbox/draw/route.test.ts +++ b/src/app/api/blindbox/draw/route.test.ts @@ -1,7 +1,10 @@ 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("@/lib/auth", () => ({ + getAuthUserId: vi.fn().mockResolvedValue("user-1"), +})); vi.mock("@/lib/blindbox", () => ({ requireMembership: vi.fn().mockResolvedValue({}), @@ -36,7 +39,7 @@ describe("POST /api/blindbox/draw", () => { const req = createRequest("/api/blindbox/draw", { method: "POST", - body: { roomId: "bb-room-1", userId: "user-1" }, + body: { roomId: "bb-room-1" }, }); const res = await POST(req, mockCtx); const { status, data } = await parseJsonResponse(res); @@ -62,7 +65,7 @@ describe("POST /api/blindbox/draw", () => { const req = createRequest("/api/blindbox/draw", { method: "POST", - body: { roomId: "bb-room-1", userId: "user-1" }, + body: { roomId: "bb-room-1" }, }); const res = await POST(req, mockCtx); expect(res.status).toBe(404); @@ -82,7 +85,7 @@ describe("POST /api/blindbox/draw", () => { const req = createRequest("/api/blindbox/draw", { method: "POST", - body: { roomId: "bb-room-1", userId: "user-1" }, + body: { roomId: "bb-room-1" }, }); const res = await POST(req, mockCtx); expect(res.status).toBe(409); @@ -91,7 +94,7 @@ describe("POST /api/blindbox/draw", () => { it("returns 400 when roomId is missing", async () => { const req = createRequest("/api/blindbox/draw", { method: "POST", - body: { userId: "user-1" }, + body: {}, }); const res = await POST(req, mockCtx); expect(res.status).toBe(400); diff --git a/src/app/api/blindbox/plan/route.test.ts b/src/app/api/blindbox/plan/route.test.ts index faa04e9..7f78da1 100644 --- a/src/app/api/blindbox/plan/route.test.ts +++ b/src/app/api/blindbox/plan/route.test.ts @@ -3,6 +3,10 @@ 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({}), })); @@ -29,7 +33,6 @@ describe("POST /api/blindbox/plan", () => { method: "POST", body: { roomId: "bb-room-1", - userId: "user-1", availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, }, }); @@ -45,7 +48,6 @@ describe("POST /api/blindbox/plan", () => { method: "POST", body: { roomId: "bb-room-1", - userId: "user-1", availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 }, }, }); @@ -57,7 +59,6 @@ describe("POST /api/blindbox/plan", () => { const req = createRequest("/api/blindbox/plan", { method: "POST", body: { - userId: "user-1", availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, }, }); @@ -76,7 +77,7 @@ describe("PATCH /api/blindbox/plan", () => { const req = createRequest("/api/blindbox/plan", { method: "PATCH", - body: { planId: "plan-1", userId: "user-1", action: "accept" }, + body: { planId: "plan-1", action: "accept" }, }); const res = await PATCH(req, mockCtx); const { status, data } = await parseJsonResponse(res); @@ -94,7 +95,7 @@ describe("PATCH /api/blindbox/plan", () => { const req = createRequest("/api/blindbox/plan", { method: "PATCH", - body: { planId: "plan-1", userId: "user-1", action: "complete" }, + body: { planId: "plan-1", action: "complete" }, }); const res = await PATCH(req, mockCtx); expect(res.status).toBe(200); @@ -109,7 +110,7 @@ describe("PATCH /api/blindbox/plan", () => { const req = createRequest("/api/blindbox/plan", { method: "PATCH", - body: { planId: "plan-1", userId: "user-1", action: "expire" }, + body: { planId: "plan-1", action: "expire" }, }); const res = await PATCH(req, mockCtx); expect(res.status).toBe(200); @@ -123,7 +124,7 @@ describe("PATCH /api/blindbox/plan", () => { const req = createRequest("/api/blindbox/plan", { method: "PATCH", - body: { planId: "plan-1", userId: "user-1", action: "accept" }, + body: { planId: "plan-1", action: "accept" }, }); const res = await PATCH(req, mockCtx); expect(res.status).toBe(400); @@ -137,7 +138,7 @@ describe("PATCH /api/blindbox/plan", () => { const req = createRequest("/api/blindbox/plan", { method: "PATCH", - body: { planId: "plan-1", userId: "user-1", action: "accept" }, + body: { planId: "plan-1", action: "accept" }, }); const res = await PATCH(req, mockCtx); expect(res.status).toBe(403); @@ -148,7 +149,7 @@ describe("PATCH /api/blindbox/plan", () => { const req = createRequest("/api/blindbox/plan", { method: "PATCH", - body: { planId: "plan-1", userId: "user-1", action: "invalid" }, + body: { planId: "plan-1", action: "invalid" }, }); const res = await PATCH(req, mockCtx); expect(res.status).toBe(400); @@ -164,7 +165,7 @@ describe("GET /api/blindbox/plan", () => { createdAt: new Date(), } as never); - const req = createRequest("/api/blindbox/plan?mode=latest&userId=user-1&roomId=bb-room-1"); + const req = createRequest("/api/blindbox/plan?mode=latest&roomId=bb-room-1"); const res = await GET(req, mockCtx); const { status, data } = await parseJsonResponse(res); @@ -176,7 +177,7 @@ describe("GET /api/blindbox/plan", () => { it("returns null when no plan found", async () => { prismaMock.weekendPlan.findFirst.mockResolvedValue(null as never); - const req = createRequest("/api/blindbox/plan?mode=latest&userId=user-1&roomId=bb-room-1"); + const req = createRequest("/api/blindbox/plan?mode=latest&roomId=bb-room-1"); const res = await GET(req, mockCtx); const { data } = await parseJsonResponse(res); @@ -184,7 +185,7 @@ describe("GET /api/blindbox/plan", () => { }); it("returns 400 for invalid mode", async () => { - const req = createRequest("/api/blindbox/plan?mode=invalid&userId=user-1"); + const req = createRequest("/api/blindbox/plan?mode=invalid"); const res = await GET(req, mockCtx); expect(res.status).toBe(400); }); diff --git a/src/app/api/blindbox/plan/route.ts b/src/app/api/blindbox/plan/route.ts index a2b61c5..2f75a29 100644 --- a/src/app/api/blindbox/plan/route.ts +++ b/src/app/api/blindbox/plan/route.ts @@ -3,6 +3,8 @@ import { requireMembership } from "@/lib/blindbox"; 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; @@ -37,97 +39,13 @@ export const POST = apiHandler(async (req) => { }); }); -/** - * 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 (base.getTime() < now.getTime()) { - base.setDate(base.getDate() + 7); - } - - return base; - } catch (e) { - console.error("computeEndTime failed:", e); - return null; - } -} - export const PATCH = apiHandler(async (req) => { const userId = await getAuthUserId(req); const { planId, action, days } = await req.json(); if (!planId) throw new ApiError("planId 不能为空"); - const { prisma } = await import("@/lib/prisma"); - 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 }); - } - - if (act === "update_plan") { - if (plan.status !== "active" && plan.status !== "accepted") { - throw new ApiError("只能编辑进行中的计划", 400); - } - if (!Array.isArray(days) || days.length === 0) { - throw new ApiError("days 数据无效", 400); - } - const newPlanData = JSON.stringify({ days }); - await prisma.weekendPlan.update({ - where: { id: planId }, - data: { - planData: newPlanData, - ...(plan.status === "accepted" - ? { endTime: computeEndTime(newPlanData, new Date()) } - : {}), - }, - }); - 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) => { @@ -135,92 +53,18 @@ export const GET = apiHandler(async (req) => { const { searchParams } = new URL(req.url); const mode = searchParams.get("mode") || "latest"; - const { prisma } = await import("@/lib/prisma"); - if (mode === "latest") { const roomId = searchParams.get("roomId"); if (!roomId) throw new ApiError("roomId 不能为空"); - - const plan = await prisma.weekendPlan.findFirst({ - where: { roomId, 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, - 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, - 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); diff --git a/src/app/api/blindbox/plan/stream/route.test.ts b/src/app/api/blindbox/plan/stream/route.test.ts index 62ca7d6..fc8dcb8 100644 --- a/src/app/api/blindbox/plan/stream/route.test.ts +++ b/src/app/api/blindbox/plan/stream/route.test.ts @@ -2,15 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/prisma", () => ({ prisma: {} })); -vi.mock("@/lib/blindbox", () => ({ - requireMembership: vi.fn().mockResolvedValue({}), +vi.mock("@/lib/auth", () => ({ + getAuthUserId: vi.fn().mockResolvedValue("user-1"), })); -vi.mock("@/lib/api", () => ({ - requireUserId: vi.fn((v) => { - if (!v || typeof v !== "string") throw new Error("请先登录"); - return v; - }), +vi.mock("@/lib/blindbox", () => ({ + requireMembership: vi.fn().mockResolvedValue({}), })); vi.mock("@/lib/blindboxPlanGen", () => ({ @@ -19,11 +16,14 @@ vi.mock("@/lib/blindboxPlanGen", () => ({ 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 { @@ -55,12 +55,11 @@ describe("POST /api/blindbox/plan/stream", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roomId: "bb-room-1", - userId: "user-1", availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, }), }); - const res = await POST(req); + const res = await POST(req as never); expect(res.status).toBe(200); expect(res.headers.get("Content-Type")).toBe("text/event-stream"); @@ -79,12 +78,11 @@ describe("POST /api/blindbox/plan/stream", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roomId: "bb-room-1", - userId: "user-1", availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, }), }); - const res = await POST(req); + const res = await POST(req as never); const text = await readStream(res); expect(text).toContain("event: error"); expect(text).toContain("AI 服务不可用"); @@ -95,12 +93,11 @@ describe("POST /api/blindbox/plan/stream", () => { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - userId: "user-1", availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, }), }); - const res = await POST(req); + const res = await POST(req as never); expect(res.status).toBe(400); }); @@ -110,16 +107,16 @@ describe("POST /api/blindbox/plan/stream", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roomId: "bb-room-1", - userId: "user-1", availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 }, }), }); - const res = await POST(req); + const res = await POST(req as never); expect(res.status).toBe(400); }); - it("returns 400 when userId is missing", async () => { + 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" }, @@ -129,7 +126,7 @@ describe("POST /api/blindbox/plan/stream", () => { }), }); - const res = await POST(req); - expect(res.status).toBe(400); + const res = await POST(req as never); + expect(res.status).toBe(401); }); }); diff --git a/src/app/api/blindbox/plan/stream/route.ts b/src/app/api/blindbox/plan/stream/route.ts index b5921fc..bd9f461 100644 --- a/src/app/api/blindbox/plan/stream/route.ts +++ b/src/app/api/blindbox/plan/stream/route.ts @@ -49,13 +49,33 @@ export async function POST(req: NextRequest): Promise { }); } + 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); @@ -65,7 +85,7 @@ export async function POST(req: NextRequest): Promise { const message = e instanceof Error ? e.message : "生成计划失败"; push("error", message); } finally { - controller.close(); + cleanup(); } }, }); diff --git a/src/app/api/blindbox/retag/route.test.ts b/src/app/api/blindbox/retag/route.test.ts index 6ca0089..031ee5e 100644 --- a/src/app/api/blindbox/retag/route.test.ts +++ b/src/app/api/blindbox/retag/route.test.ts @@ -2,6 +2,11 @@ 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({}), @@ -21,11 +26,13 @@ vi.mock("@/lib/ai", () => ({ })); 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", () => { @@ -36,7 +43,7 @@ describe("POST /api/blindbox/retag", () => { const req = createRequest("/api/blindbox/retag", { method: "POST", - body: { roomId: "bb-room-1", userId: "user-1" }, + body: { roomId: "bb-room-1" }, }); const res = await POST(req, mockCtx); const { status, data } = await parseJsonResponse(res); @@ -51,7 +58,7 @@ describe("POST /api/blindbox/retag", () => { const req = createRequest("/api/blindbox/retag", { method: "POST", - body: { roomId: "bb-room-1", userId: "user-1" }, + body: { roomId: "bb-room-1" }, }); const res = await POST(req, mockCtx); const { status, data } = await parseJsonResponse(res); @@ -60,7 +67,8 @@ describe("POST /api/blindbox/retag", () => { expect(data.retagged).toBe(0); }); - it("returns 401 when no userId", async () => { + 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" }, diff --git a/src/app/api/blindbox/room/[code]/route.test.ts b/src/app/api/blindbox/room/[code]/route.test.ts index b714ec0..70a65fe 100644 --- a/src/app/api/blindbox/room/[code]/route.test.ts +++ b/src/app/api/blindbox/room/[code]/route.test.ts @@ -3,6 +3,10 @@ import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; import { TEST_BLINDBOX_ROOM, TEST_USER } 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({}), @@ -10,12 +14,14 @@ vi.mock("@/lib/blindbox", () => ({ 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]", () => { @@ -61,7 +67,7 @@ describe("PATCH /api/blindbox/room/[code]", () => { const req = createRequest("/api/blindbox/room/ABC123", { method: "PATCH", - body: { userId: "user-1", city: "上海", lat: 31.2, lng: 121.4 }, + body: { city: "上海", lat: 31.2, lng: 121.4 }, }); const ctx = createRouteContext({ code: "ABC123" }); const res = await PATCH(req, ctx); @@ -76,7 +82,7 @@ describe("PATCH /api/blindbox/room/[code]", () => { const req = createRequest("/api/blindbox/room/ABC123", { method: "PATCH", - body: { userId: "user-1", lat: 999, lng: 121.4 }, + body: { lat: 999, lng: 121.4 }, }); const ctx = createRouteContext({ code: "ABC123" }); const res = await PATCH(req, ctx); @@ -86,13 +92,11 @@ describe("PATCH /api/blindbox/room/[code]", () => { 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: { userId: "user-1" }, - }); + 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); @@ -101,14 +105,13 @@ describe("DELETE /api/blindbox/room/[code]", () => { }); 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: { userId: "user-2" }, - }); + 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); @@ -117,13 +120,12 @@ describe("DELETE /api/blindbox/room/[code]", () => { }); 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: { userId: "stranger" }, - }); + 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); diff --git a/src/app/api/blindbox/room/join/route.test.ts b/src/app/api/blindbox/room/join/route.test.ts index 06461cf..ef6b0a5 100644 --- a/src/app/api/blindbox/room/join/route.test.ts +++ b/src/app/api/blindbox/room/join/route.test.ts @@ -3,6 +3,10 @@ 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({}) }; @@ -19,7 +23,7 @@ describe("POST /api/blindbox/room/join", () => { const req = createRequest("/api/blindbox/room/join", { method: "POST", - body: { userId: "user-2", code: "ABC123" }, + body: { code: "ABC123" }, }); const res = await POST(req, mockCtx); const { status, data } = await parseJsonResponse(res); @@ -34,7 +38,7 @@ describe("POST /api/blindbox/room/join", () => { const req = createRequest("/api/blindbox/room/join", { method: "POST", - body: { userId: "user-1", code: "ABC123" }, + body: { code: "ABC123" }, }); const res = await POST(req, mockCtx); const { data } = await parseJsonResponse(res); @@ -47,7 +51,7 @@ describe("POST /api/blindbox/room/join", () => { const req = createRequest("/api/blindbox/room/join", { method: "POST", - body: { userId: "user-1", code: "BADCODE" }, + body: { code: "BADCODE" }, }); const res = await POST(req, mockCtx); expect(res.status).toBe(404); @@ -56,7 +60,7 @@ describe("POST /api/blindbox/room/join", () => { it("returns 400 when code is missing", async () => { const req = createRequest("/api/blindbox/room/join", { method: "POST", - body: { userId: "user-1" }, + body: {}, }); const res = await POST(req, mockCtx); expect(res.status).toBe(400); diff --git a/src/app/api/blindbox/room/route.test.ts b/src/app/api/blindbox/room/route.test.ts index 116e5b4..be47c65 100644 --- a/src/app/api/blindbox/room/route.test.ts +++ b/src/app/api/blindbox/room/route.test.ts @@ -2,17 +2,24 @@ 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", () => { @@ -25,7 +32,7 @@ describe("POST /api/blindbox/room", () => { const req = createRequest("/api/blindbox/room", { method: "POST", - body: { userId: "user-1", name: "周末计划" }, + body: { name: "周末计划" }, }); const res = await POST(req, mockCtx); const { status, data } = await parseJsonResponse(res); @@ -40,13 +47,14 @@ describe("POST /api/blindbox/room", () => { const req = createRequest("/api/blindbox/room", { method: "POST", - body: { userId: "user-1" }, + body: {}, }); const res = await POST(req, mockCtx); expect(res.status).toBe(201); }); - it("returns 401 when no userId", async () => { + 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" }, @@ -58,7 +66,7 @@ describe("POST /api/blindbox/room", () => { it("returns 400 when room name too long", async () => { const req = createRequest("/api/blindbox/room", { method: "POST", - body: { userId: "user-1", name: "a".repeat(31) }, + body: { name: "a".repeat(31) }, }); const res = await POST(req, mockCtx); expect(res.status).toBe(400); diff --git a/src/app/api/blindbox/rooms/route.test.ts b/src/app/api/blindbox/rooms/route.test.ts index 500d10e..c5ce695 100644 --- a/src/app/api/blindbox/rooms/route.test.ts +++ b/src/app/api/blindbox/rooms/route.test.ts @@ -1,13 +1,20 @@ 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", () => { @@ -33,7 +40,7 @@ describe("GET /api/blindbox/rooms", () => { { roomId: "bb-room-1", _count: 3 }, ] as never); - const req = createRequest("/api/blindbox/rooms?userId=user-1"); + const req = createRequest("/api/blindbox/rooms"); const res = await GET(req, mockCtx); const { status, data } = await parseJsonResponse(res); @@ -43,7 +50,8 @@ describe("GET /api/blindbox/rooms", () => { expect(data.rooms[0].poolCount).toBe(3); }); - it("returns 401 when no userId", async () => { + 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); diff --git a/src/app/api/blindbox/route.test.ts b/src/app/api/blindbox/route.test.ts index 31da94b..34b1dbf 100644 --- a/src/app/api/blindbox/route.test.ts +++ b/src/app/api/blindbox/route.test.ts @@ -2,6 +2,11 @@ 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({}), @@ -21,11 +26,13 @@ vi.mock("@/lib/ai", () => ({ })); 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)", () => { @@ -35,7 +42,7 @@ describe("POST /api/blindbox (create idea)", () => { const req = createRequest("/api/blindbox", { method: "POST", - body: { roomId: "bb-room-1", userId: "user-1", content: "去公园野餐" }, + body: { roomId: "bb-room-1", content: "去公园野餐" }, }); const res = await POST(req, mockCtx); const { status, data } = await parseJsonResponse(res); @@ -44,7 +51,8 @@ describe("POST /api/blindbox (create idea)", () => { expect(data.id).toBe("idea-1"); }); - it("returns 401 when no userId", async () => { + 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" }, @@ -56,7 +64,7 @@ describe("POST /api/blindbox (create idea)", () => { it("returns 400 when content is empty", async () => { const req = createRequest("/api/blindbox", { method: "POST", - body: { roomId: "bb-room-1", userId: "user-1", content: "" }, + body: { roomId: "bb-room-1", content: "" }, }); const res = await POST(req, mockCtx); expect(res.status).toBe(400); @@ -65,7 +73,7 @@ describe("POST /api/blindbox (create idea)", () => { it("returns 400 when content over 200 chars", async () => { const req = createRequest("/api/blindbox", { method: "POST", - body: { roomId: "bb-room-1", userId: "user-1", content: "a".repeat(201) }, + body: { roomId: "bb-room-1", content: "a".repeat(201) }, }); const res = await POST(req, mockCtx); expect(res.status).toBe(400); @@ -79,7 +87,7 @@ describe("GET /api/blindbox (get pool data)", () => { .mockResolvedValueOnce([TEST_BLINDBOX_IDEA] as never) .mockResolvedValueOnce([] as never); - const req = createRequest("/api/blindbox?userId=user-1&roomId=bb-room-1"); + const req = createRequest("/api/blindbox?roomId=bb-room-1"); const res = await GET(req, mockCtx); const { status, data } = await parseJsonResponse(res); @@ -89,7 +97,8 @@ describe("GET /api/blindbox (get pool data)", () => { expect(data.drawn).toHaveLength(0); }); - it("returns 401 when no userId", async () => { + 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); @@ -102,7 +111,7 @@ describe("PUT /api/blindbox (edit idea)", () => { const req = createRequest("/api/blindbox", { method: "PUT", - body: { ideaId: "idea-1", userId: "user-1", content: "去公园散步" }, + body: { ideaId: "idea-1", content: "去公园散步" }, }); const res = await PUT(req, mockCtx); const { status, data } = await parseJsonResponse(res); @@ -116,7 +125,7 @@ describe("PUT /api/blindbox (edit idea)", () => { const req = createRequest("/api/blindbox", { method: "PUT", - body: { ideaId: "nonexistent", userId: "user-1", content: "test" }, + body: { ideaId: "nonexistent", content: "test" }, }); const res = await PUT(req, mockCtx); expect(res.status).toBe(404); @@ -129,7 +138,7 @@ describe("DELETE /api/blindbox (delete idea)", () => { const req = createRequest("/api/blindbox", { method: "DELETE", - body: { ideaId: "idea-1", userId: "user-1" }, + body: { ideaId: "idea-1" }, }); const res = await DELETE(req, mockCtx); const { data } = await parseJsonResponse(res); @@ -142,7 +151,7 @@ describe("DELETE /api/blindbox (delete idea)", () => { const req = createRequest("/api/blindbox", { method: "DELETE", - body: { ideaId: "nonexistent", userId: "user-1" }, + body: { ideaId: "nonexistent" }, }); const res = await DELETE(req, mockCtx); expect(res.status).toBe(404); diff --git a/src/app/api/blindbox/suggest/route.test.ts b/src/app/api/blindbox/suggest/route.test.ts index a041782..1546591 100644 --- a/src/app/api/blindbox/suggest/route.test.ts +++ b/src/app/api/blindbox/suggest/route.test.ts @@ -2,6 +2,10 @@ 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({}), })); diff --git a/src/app/api/room/[id]/events/route.ts b/src/app/api/room/[id]/events/route.ts index 037413e..01d4108 100644 --- a/src/app/api/room/[id]/events/route.ts +++ b/src/app/api/room/[id]/events/route.ts @@ -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"; diff --git a/src/app/api/room/[id]/join/route.test.ts b/src/app/api/room/[id]/join/route.test.ts index f98bbba..84f654b 100644 --- a/src/app/api/room/[id]/join/route.test.ts +++ b/src/app/api/room/[id]/join/route.test.ts @@ -4,7 +4,7 @@ import { TEST_ROOM_DATA } from "@/__tests__/helpers/fixtures"; vi.mock("@/lib/prisma", () => ({ prisma: {} })); -vi.mock("@/lib/store", () => ({ +vi.mock("@/lib/roomRepository", () => ({ atomicUpdateRoom: vi.fn(), })); @@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({ })); import { POST } from "./route"; -import { atomicUpdateRoom } from "@/lib/store"; +import { atomicUpdateRoom } from "@/lib/roomRepository"; const mockAtomicUpdate = vi.mocked(atomicUpdateRoom); diff --git a/src/app/api/room/[id]/join/route.ts b/src/app/api/room/[id]/join/route.ts index 7f7c96b..ee8a357 100644 --- a/src/app/api/room/[id]/join/route.ts +++ b/src/app/api/room/[id]/join/route.ts @@ -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"; diff --git a/src/app/api/room/[id]/manage/route.test.ts b/src/app/api/room/[id]/manage/route.test.ts index 656d247..ba48141 100644 --- a/src/app/api/room/[id]/manage/route.test.ts +++ b/src/app/api/room/[id]/manage/route.test.ts @@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures"; vi.mock("@/lib/prisma", () => ({ prisma: {} })); -vi.mock("@/lib/store", () => ({ +vi.mock("@/lib/roomRepository", () => ({ atomicUpdateRoom: vi.fn(), })); @@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({ })); import { POST } from "./route"; -import { atomicUpdateRoom } from "@/lib/store"; +import { atomicUpdateRoom } from "@/lib/roomRepository"; const mockAtomicUpdate = vi.mocked(atomicUpdateRoom); diff --git a/src/app/api/room/[id]/manage/route.ts b/src/app/api/room/[id]/manage/route.ts index a806676..44bdfa5 100644 --- a/src/app/api/room/[id]/manage/route.ts +++ b/src/app/api/room/[id]/manage/route.ts @@ -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"; diff --git a/src/app/api/room/[id]/reset/route.test.ts b/src/app/api/room/[id]/reset/route.test.ts index a4bdd1d..cfd6b78 100644 --- a/src/app/api/room/[id]/reset/route.test.ts +++ b/src/app/api/room/[id]/reset/route.test.ts @@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT, TEST_RESTAURANT_2 } from "@/__tests__/ vi.mock("@/lib/prisma", () => ({ prisma: {} })); -vi.mock("@/lib/store", () => ({ +vi.mock("@/lib/roomRepository", () => ({ atomicUpdateRoom: vi.fn(), })); @@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({ })); import { POST } from "./route"; -import { atomicUpdateRoom } from "@/lib/store"; +import { atomicUpdateRoom } from "@/lib/roomRepository"; const mockAtomicUpdate = vi.mocked(atomicUpdateRoom); diff --git a/src/app/api/room/[id]/reset/route.ts b/src/app/api/room/[id]/reset/route.ts index 031ace1..bfc5cbe 100644 --- a/src/app/api/room/[id]/reset/route.ts +++ b/src/app/api/room/[id]/reset/route.ts @@ -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"; diff --git a/src/app/api/room/[id]/swipe/route.test.ts b/src/app/api/room/[id]/swipe/route.test.ts index 14face5..0e62941 100644 --- a/src/app/api/room/[id]/swipe/route.test.ts +++ b/src/app/api/room/[id]/swipe/route.test.ts @@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures"; vi.mock("@/lib/prisma", () => ({ prisma: {} })); -vi.mock("@/lib/store", () => ({ +vi.mock("@/lib/roomRepository", () => ({ atomicUpdateRoom: vi.fn(), })); @@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({ })); import { POST } from "./route"; -import { atomicUpdateRoom } from "@/lib/store"; +import { atomicUpdateRoom } from "@/lib/roomRepository"; const mockAtomicUpdate = vi.mocked(atomicUpdateRoom); diff --git a/src/app/api/room/[id]/swipe/route.ts b/src/app/api/room/[id]/swipe/route.ts index 88663af..a84f380 100644 --- a/src/app/api/room/[id]/swipe/route.ts +++ b/src/app/api/room/[id]/swipe/route.ts @@ -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"; diff --git a/src/app/api/room/[id]/undo/route.test.ts b/src/app/api/room/[id]/undo/route.test.ts index a06b5e6..7b31aa2 100644 --- a/src/app/api/room/[id]/undo/route.test.ts +++ b/src/app/api/room/[id]/undo/route.test.ts @@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures"; vi.mock("@/lib/prisma", () => ({ prisma: {} })); -vi.mock("@/lib/store", () => ({ +vi.mock("@/lib/roomRepository", () => ({ atomicUpdateRoom: vi.fn(), })); @@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({ })); import { POST } from "./route"; -import { atomicUpdateRoom } from "@/lib/store"; +import { atomicUpdateRoom } from "@/lib/roomRepository"; const mockAtomicUpdate = vi.mocked(atomicUpdateRoom); diff --git a/src/app/api/room/[id]/undo/route.ts b/src/app/api/room/[id]/undo/route.ts index 8b37b18..73e3a20 100644 --- a/src/app/api/room/[id]/undo/route.ts +++ b/src/app/api/room/[id]/undo/route.ts @@ -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"; diff --git a/src/app/api/room/create/route.test.ts b/src/app/api/room/create/route.test.ts index 6484b2a..ff8bab7 100644 --- a/src/app/api/room/create/route.test.ts +++ b/src/app/api/room/create/route.test.ts @@ -5,7 +5,7 @@ vi.mock("@/lib/prisma", () => ({ prisma: {}, })); -vi.mock("@/lib/store", () => ({ +vi.mock("@/lib/roomRepository", () => ({ createRoom: vi.fn().mockResolvedValue("ROOM01"), })); @@ -17,7 +17,7 @@ const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); import { POST } from "./route"; -import { createRoom } from "@/lib/store"; +import { createRoom } from "@/lib/roomRepository"; const mockCtx = { params: Promise.resolve({}) }; diff --git a/src/app/api/room/create/route.ts b/src/app/api/room/create/route.ts index 78ead33..9be2b4a 100644 --- a/src/app/api/room/create/route.ts +++ b/src/app/api/room/create/route.ts @@ -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"; diff --git a/src/app/api/user/achievements/route.test.ts b/src/app/api/user/achievements/route.test.ts index 59173ab..7e11259 100644 --- a/src/app/api/user/achievements/route.test.ts +++ b/src/app/api/user/achievements/route.test.ts @@ -2,17 +2,25 @@ 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 no userId", async () => { + 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); @@ -48,7 +56,7 @@ describe("GET /api/user/achievements", () => { { id: "bb-room-1", name: "周末", code: "ABC123" }, ] as never); - const req = createRequest("/api/user/achievements?userId=user-1"); + const req = createRequest("/api/user/achievements"); const res = await GET(req, mockCtx); const { status, data } = await parseJsonResponse(res); diff --git a/src/app/api/user/favorite/route.test.ts b/src/app/api/user/favorite/route.test.ts index 34151b3..983dc7d 100644 --- a/src/app/api/user/favorite/route.test.ts +++ b/src/app/api/user/favorite/route.test.ts @@ -2,17 +2,25 @@ 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 userId", async () => { + 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); @@ -30,7 +38,7 @@ describe("GET /api/user/favorite", () => { }, ] as never); - const req = createRequest("/api/user/favorite?userId=user-1"); + const req = createRequest("/api/user/favorite"); const res = await GET(req, mockCtx); const { status, data } = await parseJsonResponse(res); @@ -48,7 +56,7 @@ describe("POST /api/user/favorite", () => { const req = createRequest("/api/user/favorite", { method: "POST", - body: { userId: "user-1", restaurant: TEST_RESTAURANT }, + body: { restaurant: TEST_RESTAURANT }, }); const res = await POST(req, mockCtx); const { status, data } = await parseJsonResponse(res); @@ -70,7 +78,7 @@ describe("POST /api/user/favorite", () => { const req = createRequest("/api/user/favorite", { method: "POST", - body: { userId: "user-1", restaurant: TEST_RESTAURANT }, + body: { restaurant: TEST_RESTAURANT }, }); const res = await POST(req, mockCtx); const { data } = await parseJsonResponse(res); @@ -79,7 +87,8 @@ describe("POST /api/user/favorite", () => { expect(data.id).toBe("fav-existing"); }); - it("returns 401 when no userId", async () => { + 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 }, @@ -91,7 +100,7 @@ describe("POST /api/user/favorite", () => { it("returns 400 when no restaurant", async () => { const req = createRequest("/api/user/favorite", { method: "POST", - body: { userId: "user-1" }, + body: {}, }); const res = await POST(req, mockCtx); expect(res.status).toBe(400); @@ -108,7 +117,7 @@ describe("DELETE /api/user/favorite", () => { const req = createRequest("/api/user/favorite", { method: "DELETE", - body: { userId: "user-1", favoriteId: "fav-1" }, + body: { favoriteId: "fav-1" }, }); const res = await DELETE(req, mockCtx); const { data } = await parseJsonResponse(res); @@ -120,7 +129,7 @@ describe("DELETE /api/user/favorite", () => { const req = createRequest("/api/user/favorite", { method: "DELETE", - body: { userId: "user-1", favoriteId: "nonexistent" }, + body: { favoriteId: "nonexistent" }, }); const res = await DELETE(req, mockCtx); expect(res.status).toBe(404); @@ -134,7 +143,7 @@ describe("DELETE /api/user/favorite", () => { const req = createRequest("/api/user/favorite", { method: "DELETE", - body: { userId: "user-1", favoriteId: "fav-1" }, + body: { favoriteId: "fav-1" }, }); const res = await DELETE(req, mockCtx); expect(res.status).toBe(404); diff --git a/src/app/api/user/history/route.test.ts b/src/app/api/user/history/route.test.ts index f4746ab..572764b 100644 --- a/src/app/api/user/history/route.test.ts +++ b/src/app/api/user/history/route.test.ts @@ -2,17 +2,25 @@ 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 userId", async () => { + 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); @@ -33,7 +41,7 @@ describe("GET /api/user/history", () => { }, ] as never); - const req = createRequest("/api/user/history?userId=user-1"); + const req = createRequest("/api/user/history"); const res = await GET(req, mockCtx); const { status, data } = await parseJsonResponse(res); @@ -53,7 +61,6 @@ describe("POST /api/user/history", () => { const req = createRequest("/api/user/history", { method: "POST", body: { - userId: "user-1", roomId: "room-1", restaurant: TEST_RESTAURANT, matchType: "unanimous", @@ -74,7 +81,6 @@ describe("POST /api/user/history", () => { const req = createRequest("/api/user/history", { method: "POST", body: { - userId: "user-1", roomId: "room-1", restaurant: TEST_RESTAURANT, matchType: "unanimous", @@ -97,7 +103,6 @@ describe("POST /api/user/history", () => { const req = createRequest("/api/user/history", { method: "POST", body: { - userId: "user-1", roomId: "room-1", restaurant: TEST_RESTAURANT, matchType: "best", @@ -111,13 +116,14 @@ describe("POST /api/user/history", () => { it("returns 400 when missing required fields", async () => { const req = createRequest("/api/user/history", { method: "POST", - body: { userId: "user-1" }, + body: {}, }); const res = await POST(req, mockCtx); expect(res.status).toBe(400); }); - it("returns 401 when no userId", async () => { + 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" }, diff --git a/src/app/api/user/route.test.ts b/src/app/api/user/route.test.ts index 865db66..b3439a0 100644 --- a/src/app/api/user/route.test.ts +++ b/src/app/api/user/route.test.ts @@ -2,6 +2,11 @@ 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: { @@ -12,12 +17,14 @@ vi.mock("bcryptjs", () => ({ 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", () => { @@ -65,7 +72,7 @@ describe("PUT /api/user", () => { const req = createRequest("/api/user", { method: "PUT", - body: { userId: "user-1", username: "newname" }, + body: { username: "newname" }, }); const res = await PUT(req, mockCtx); const { status, data } = await parseJsonResponse(res); @@ -81,7 +88,7 @@ describe("PUT /api/user", () => { const req = createRequest("/api/user", { method: "PUT", - body: { userId: "user-1", username: "takenname" }, + body: { username: "takenname" }, }); const res = await PUT(req, mockCtx); expect(res.status).toBe(409); @@ -94,7 +101,7 @@ describe("PUT /api/user", () => { const req = createRequest("/api/user", { method: "PUT", - body: { userId: "user-1", currentPassword: "old", newPassword: "newpass123" }, + body: { currentPassword: "old", newPassword: "newpass123" }, }); const res = await PUT(req, mockCtx); expect(res.status).toBe(200); @@ -106,7 +113,7 @@ describe("PUT /api/user", () => { const req = createRequest("/api/user", { method: "PUT", - body: { userId: "user-1", currentPassword: "wrong", newPassword: "newpass123" }, + body: { currentPassword: "wrong", newPassword: "newpass123" }, }); const res = await PUT(req, mockCtx); expect(res.status).toBe(403); @@ -117,13 +124,14 @@ describe("PUT /api/user", () => { const req = createRequest("/api/user", { method: "PUT", - body: { userId: "user-1", newPassword: "newpass123" }, + body: { newPassword: "newpass123" }, }); const res = await PUT(req, mockCtx); expect(res.status).toBe(400); }); - it("returns 401 when no userId", async () => { + it("returns 401 when not authenticated", async () => { + vi.mocked(getAuthUserId).mockRejectedValueOnce(new ApiError("请先登录", 401)); const req = createRequest("/api/user", { method: "PUT", body: { username: "test" }, @@ -138,7 +146,7 @@ describe("PUT /api/user", () => { const req = createRequest("/api/user", { method: "PUT", - body: { userId: "user-1", avatar: "🦊" }, + body: { avatar: "🦊" }, }); const res = await PUT(req, mockCtx); const { data } = await parseJsonResponse(res); @@ -155,7 +163,7 @@ describe("PUT /api/user", () => { const req = createRequest("/api/user", { method: "PUT", - body: { userId: "user-1", email: "new@example.com" }, + body: { email: "new@example.com" }, }); const res = await PUT(req, mockCtx); expect(res.status).toBe(200); @@ -166,7 +174,7 @@ describe("PUT /api/user", () => { const req = createRequest("/api/user", { method: "PUT", - body: { userId: "user-1", email: "notanemail" }, + body: { email: "notanemail" }, }); const res = await PUT(req, mockCtx); expect(res.status).toBe(400); diff --git a/src/app/profile/ProfileClient.tsx b/src/app/profile/ProfileClient.tsx new file mode 100644 index 0000000..2a2442f --- /dev/null +++ b/src/app/profile/ProfileClient.tsx @@ -0,0 +1,501 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { motion, AnimatePresence } from "framer-motion"; +import { + ArrowLeft, + Mail, + Loader2, + LogOut, + Lock, + Edit3, + Check, + X, + Eye, + EyeOff, + Zap, + Trophy, + ChevronRight, +} from "lucide-react"; +import Card from "@/components/Card"; +import Input from "@/components/Input"; +import ProfileFavoritesCard from "@/components/ProfileFavoritesCard"; +import { useToast } from "@/hooks/useToast"; +import { useFavorites } from "@/hooks/useFavorites"; +import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton"; +import { setCachedProfile, setCachedPreferences, logout } from "@/lib/userId"; +import { getAvatarBg, AVATARS } from "@/lib/avatars"; +import type { UserProfile, UserPreferences } from "@/types"; + +export default function ProfilePage({ initialUserId }: { initialUserId: string }) { + const router = useRouter(); + const [userId] = useState(initialUserId); + const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences; decisionCount?: number }) | null>(null); + const [loading, setLoading] = useState(true); + + const { favorites, isLoading: favLoading, mutate: mutateFavorites } = useFavorites(userId || undefined); + + const [editingUsername, setEditingUsername] = useState(false); + const [newUsername, setNewUsername] = useState(""); + const [usernameSaving, setUsernameSaving] = useState(false); + const [usernameMsg, setUsernameMsg] = useState(""); + + const [editingPassword, setEditingPassword] = useState(false); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [passwordSaving, setPasswordSaving] = useState(false); + const [passwordMsg, setPasswordMsg] = useState(""); + + const [editingAvatar, setEditingAvatar] = useState(false); + + const [email, setEmail] = useState(""); + const [emailSaving, setEmailSaving] = useState(false); + const [emailMsg, setEmailMsg] = useState(""); + + const [showFavorites, setShowFavorites] = useState(true); + const toast = useToast(); + + useEffect(() => { + fetch(`/api/user?id=${userId}`) + .then((r) => r.json()) + .then((data) => { + if (data) { + setProfile(data); + setEmail(data.email ?? ""); + setCachedProfile({ id: data.id, username: data.username, avatar: data.avatar }); + if (data.preferences) setCachedPreferences(data.preferences); + } else { + router.push("/"); + } + }) + .catch((e) => { + console.error("ProfilePage: fetch user failed:", e); + }) + .finally(() => setLoading(false)); + }, [userId, router]); + + const handleSaveUsername = async () => { + const trimmed = newUsername.trim(); + if (trimmed.length < 2 || trimmed.length > 16) { + setUsernameMsg("用户名需要 2-16 个字符"); + return; + } + + setUsernameSaving(true); + setUsernameMsg(""); + try { + const res = await fetch("/api/user", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, username: trimmed }), + }); + const data = await res.json(); + if (res.ok) { + setProfile((prev) => prev ? { ...prev, username: trimmed } : prev); + setCachedProfile({ id: userId, username: trimmed, avatar: profile!.avatar }); + setEditingUsername(false); + toast.show("用户名已更新"); + } else { + setUsernameMsg(data.error ?? "更新失败"); + } + } catch { + setUsernameMsg("网络错误"); + } finally { + setUsernameSaving(false); + } + }; + + const handleSavePassword = async () => { + if (!currentPassword) { + setPasswordMsg("请输入当前密码"); + return; + } + if (newPassword.length < 6) { + setPasswordMsg("新密码至少 6 个字符"); + return; + } + if (newPassword !== confirmPassword) { + setPasswordMsg("两次密码不一致"); + return; + } + + setPasswordSaving(true); + setPasswordMsg(""); + try { + const res = await fetch("/api/user", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, currentPassword, newPassword }), + }); + const data = await res.json(); + if (res.ok) { + setEditingPassword(false); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + toast.show("密码已更新"); + } else { + setPasswordMsg(data.error ?? "更新失败"); + } + } catch { + setPasswordMsg("网络错误"); + } finally { + setPasswordSaving(false); + } + }; + + const handleSaveAvatar = async (emoji: string) => { + try { + const res = await fetch("/api/user", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, avatar: emoji }), + }); + if (res.ok) { + setProfile((prev) => prev ? { ...prev, avatar: emoji } : prev); + setCachedProfile({ id: userId, username: profile!.username, avatar: emoji }); + setEditingAvatar(false); + toast.show("头像已更新"); + } + } catch { + toast.show("更新失败"); + } + }; + + const handleSaveEmail = async () => { + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + setEmailMsg("邮箱格式不正确"); + return; + } + + setEmailSaving(true); + setEmailMsg(""); + try { + const res = await fetch("/api/user", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, email: email || null }), + }); + if (res.ok) { + setEmailMsg(email ? "邮箱已绑定" : "邮箱已解绑"); + } else { + const data = await res.json().catch(() => ({})); + setEmailMsg(data.error ?? "保存失败"); + } + } catch { + setEmailMsg("网络错误"); + } finally { + setEmailSaving(false); + } + }; + + const handleRemoveFavorite = async (favId: string) => { + try { + await fetch("/api/user/favorite", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, favoriteId: favId }), + }); + mutateFavorites((prev) => prev?.filter((x) => x.id !== favId), false); + toast.show("已取消收藏"); + } catch { + toast.show("操作失败"); + } + }; + + const handleLogout = async () => { + await logout(); + router.push("/"); + }; + + if (loading) { + return ( +
+ +
+ + +
+ + +
+
+ +
+ + +
+
+
+
+ ); + } + + if (!profile) return null; + + + return ( +
+ + +
+ {/* Profile card */} + +
+ +
+ {editingUsername ? ( +
+ { + setNewUsername(e.target.value.slice(0, 16)); + setUsernameMsg(""); + }} + maxLength={16} + autoFocus + size="sm" + className="flex-1" + /> + + +
+ ) : ( +
+

{profile.username}

+ +
+ )} + {usernameMsg &&

{usernameMsg}

} + {(profile.decisionCount ?? 0) > 0 && ( +

+ + 已拯救 {profile.decisionCount} 次选择困难症 +

+ )} +
+
+ + {/* Avatar picker */} + + {editingAvatar && ( + +
+ {AVATARS.map((a) => ( + + ))} +
+
+ )} +
+
+ + {/* Change password */} + + + + + {editingPassword && ( + +
+
+

当前密码

+
+ { setCurrentPassword(e.target.value); setPasswordMsg(""); }} + className="pr-9" + /> + +
+
+
+

新密码

+ { setNewPassword(e.target.value); setPasswordMsg(""); }} + placeholder="至少 6 个字符" + className="mt-1" + /> +
+
+

确认新密码

+ { setConfirmPassword(e.target.value); setPasswordMsg(""); }} + placeholder="再次输入新密码" + className="mt-1" + /> +
+ + {passwordMsg && ( +

+ {passwordMsg} +

+ )} + + +
+
+ )} +
+
+ + {/* Email binding */} + +
+ +

绑定邮箱

+ (可选) +
+
+ { + setEmail(e.target.value); + setEmailMsg(""); + }} + className="flex-1" + /> + +
+ {emailMsg && ( +

+ {emailMsg} +

+ )} +
+ + {/* Achievements link */} + + + + + setShowFavorites((v) => !v)} + onRemove={handleRemoveFavorite} + onEmpty={() => router.push("/blindbox")} + delay={0.2} + /> + + {/* Logout */} + + + +
+ +
+ ); +} diff --git a/src/app/profile/page.test.tsx b/src/app/profile/page.test.tsx index 109afd3..c87f0b8 100644 --- a/src/app/profile/page.test.tsx +++ b/src/app/profile/page.test.tsx @@ -33,7 +33,7 @@ vi.mock("@/components/ProfileFavoritesCard", () => ({ const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); -import ProfilePage from "./page"; +import ProfilePage from "./ProfileClient"; const toastCtx: ToastContextValue = { show: vi.fn() }; @@ -42,7 +42,7 @@ function renderPage() { React.createElement( ToastContext.Provider, { value: toastCtx }, - React.createElement(ProfilePage), + React.createElement(ProfilePage, { initialUserId: "user-1" }), ), ); } diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 25eec50..dc5bd91 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -1,511 +1,20 @@ -"use client"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { verifyToken } from "@/lib/auth"; +import ProfileClient from "./ProfileClient"; -import { useState, useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { motion, AnimatePresence } from "framer-motion"; -import { - ArrowLeft, - Mail, - Loader2, - LogOut, - Lock, - Edit3, - Check, - X, - Eye, - EyeOff, - Zap, - Trophy, - ChevronRight, -} from "lucide-react"; -import Card from "@/components/Card"; -import Input from "@/components/Input"; -import ProfileFavoritesCard from "@/components/ProfileFavoritesCard"; -import { useToast } from "@/hooks/useToast"; -import { useFavorites } from "@/hooks/useFavorites"; -import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton"; -import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId"; -import { getAvatarBg, AVATARS } from "@/lib/avatars"; -import type { UserProfile, UserPreferences } from "@/types"; +export default async function ProfilePage() { + const cookieStore = await cookies(); + const token = cookieStore.get("nw_token")?.value; -export default function ProfilePage() { - const router = useRouter(); - const [userId, setUserId] = useState(""); - const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences; decisionCount?: number }) | null>(null); - const [loading, setLoading] = useState(true); + if (!token) redirect("/"); - const { favorites, isLoading: favLoading, mutate: mutateFavorites } = useFavorites(userId || undefined); - - const [editingUsername, setEditingUsername] = useState(false); - const [newUsername, setNewUsername] = useState(""); - const [usernameSaving, setUsernameSaving] = useState(false); - const [usernameMsg, setUsernameMsg] = useState(""); - - const [editingPassword, setEditingPassword] = useState(false); - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [showPassword, setShowPassword] = useState(false); - const [passwordSaving, setPasswordSaving] = useState(false); - const [passwordMsg, setPasswordMsg] = useState(""); - - const [editingAvatar, setEditingAvatar] = useState(false); - - const [email, setEmail] = useState(""); - const [emailSaving, setEmailSaving] = useState(false); - const [emailMsg, setEmailMsg] = useState(""); - - const [showFavorites, setShowFavorites] = useState(true); - const toast = useToast(); - - useEffect(() => { - const cached = getCachedProfile(); - if (!cached) { - router.push("/"); - return; - } - - const id = getUserId(); - setUserId(id); - - fetch(`/api/user?id=${id}`) - .then((r) => r.json()) - .then((data) => { - if (data) { - setProfile(data); - setEmail(data.email ?? ""); - setCachedProfile({ id: data.id, username: data.username, avatar: data.avatar }); - if (data.preferences) setCachedPreferences(data.preferences); - } else { - router.push("/"); - } - }) - .catch((e) => { - console.error("ProfilePage: fetch user failed:", e); - setProfile({ ...cached }); - }) - .finally(() => setLoading(false)); - }, [router]); - - const handleSaveUsername = async () => { - const trimmed = newUsername.trim(); - if (trimmed.length < 2 || trimmed.length > 16) { - setUsernameMsg("用户名需要 2-16 个字符"); - return; - } - - setUsernameSaving(true); - setUsernameMsg(""); - try { - const res = await fetch("/api/user", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId, username: trimmed }), - }); - const data = await res.json(); - if (res.ok) { - setProfile((prev) => prev ? { ...prev, username: trimmed } : prev); - setCachedProfile({ id: userId, username: trimmed, avatar: profile!.avatar }); - setEditingUsername(false); - toast.show("用户名已更新"); - } else { - setUsernameMsg(data.error ?? "更新失败"); - } - } catch { - setUsernameMsg("网络错误"); - } finally { - setUsernameSaving(false); - } - }; - - const handleSavePassword = async () => { - if (!currentPassword) { - setPasswordMsg("请输入当前密码"); - return; - } - if (newPassword.length < 6) { - setPasswordMsg("新密码至少 6 个字符"); - return; - } - if (newPassword !== confirmPassword) { - setPasswordMsg("两次密码不一致"); - return; - } - - setPasswordSaving(true); - setPasswordMsg(""); - try { - const res = await fetch("/api/user", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId, currentPassword, newPassword }), - }); - const data = await res.json(); - if (res.ok) { - setEditingPassword(false); - setCurrentPassword(""); - setNewPassword(""); - setConfirmPassword(""); - toast.show("密码已更新"); - } else { - setPasswordMsg(data.error ?? "更新失败"); - } - } catch { - setPasswordMsg("网络错误"); - } finally { - setPasswordSaving(false); - } - }; - - const handleSaveAvatar = async (emoji: string) => { - try { - const res = await fetch("/api/user", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId, avatar: emoji }), - }); - if (res.ok) { - setProfile((prev) => prev ? { ...prev, avatar: emoji } : prev); - setCachedProfile({ id: userId, username: profile!.username, avatar: emoji }); - setEditingAvatar(false); - toast.show("头像已更新"); - } - } catch { - toast.show("更新失败"); - } - }; - - const handleSaveEmail = async () => { - if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - setEmailMsg("邮箱格式不正确"); - return; - } - - setEmailSaving(true); - setEmailMsg(""); - try { - const res = await fetch("/api/user", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId, email: email || null }), - }); - if (res.ok) { - setEmailMsg(email ? "邮箱已绑定" : "邮箱已解绑"); - } else { - const data = await res.json().catch(() => ({})); - setEmailMsg(data.error ?? "保存失败"); - } - } catch { - setEmailMsg("网络错误"); - } finally { - setEmailSaving(false); - } - }; - - const handleRemoveFavorite = async (favId: string) => { - try { - await fetch("/api/user/favorite", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId, favoriteId: favId }), - }); - mutateFavorites((prev) => prev?.filter((x) => x.id !== favId), false); - toast.show("已取消收藏"); - } catch { - toast.show("操作失败"); - } - }; - - const handleLogout = async () => { - await logout(); - router.push("/"); - }; - - if (loading) { - return ( -
- -
- - -
- - -
-
- -
- - -
-
-
-
- ); + let userId: string; + try { + userId = await verifyToken(token); + } catch { + redirect("/"); } - if (!profile) return null; - - - return ( -
- - -
- {/* Profile card */} - -
- -
- {editingUsername ? ( -
- { - setNewUsername(e.target.value.slice(0, 16)); - setUsernameMsg(""); - }} - maxLength={16} - autoFocus - size="sm" - className="flex-1" - /> - - -
- ) : ( -
-

{profile.username}

- -
- )} - {usernameMsg &&

{usernameMsg}

} - {(profile.decisionCount ?? 0) > 0 && ( -

- - 已拯救 {profile.decisionCount} 次选择困难症 -

- )} -
-
- - {/* Avatar picker */} - - {editingAvatar && ( - -
- {AVATARS.map((a) => ( - - ))} -
-
- )} -
-
- - {/* Change password */} - - - - - {editingPassword && ( - -
-
-

当前密码

-
- { setCurrentPassword(e.target.value); setPasswordMsg(""); }} - className="pr-9" - /> - -
-
-
-

新密码

- { setNewPassword(e.target.value); setPasswordMsg(""); }} - placeholder="至少 6 个字符" - className="mt-1" - /> -
-
-

确认新密码

- { setConfirmPassword(e.target.value); setPasswordMsg(""); }} - placeholder="再次输入新密码" - className="mt-1" - /> -
- - {passwordMsg && ( -

- {passwordMsg} -

- )} - - -
-
- )} -
-
- - {/* Email binding */} - -
- -

绑定邮箱

- (可选) -
-
- { - setEmail(e.target.value); - setEmailMsg(""); - }} - className="flex-1" - /> - -
- {emailMsg && ( -

- {emailMsg} -

- )} -
- - {/* Achievements link */} - - - - - setShowFavorites((v) => !v)} - onRemove={handleRemoveFavorite} - onEmpty={() => router.push("/blindbox")} - delay={0.2} - /> - - {/* Logout */} - - - -
- -
- ); + return ; } diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx index 237a2f1..d161723 100644 --- a/src/components/AuthModal.tsx +++ b/src/components/AuthModal.tsx @@ -121,21 +121,24 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login" }; return ( - +
- 欢迎 + 欢迎
-
+
{(["login", "register"] as const).map((t) => (
diff --git a/src/components/QrInviteModal.tsx b/src/components/QrInviteModal.tsx index 211efa1..3ba8d35 100644 --- a/src/components/QrInviteModal.tsx +++ b/src/components/QrInviteModal.tsx @@ -43,6 +43,7 @@ export default function QrInviteModal({