refactor(P2/P3): 完成全部7批重构 — 模块化、SSE退避、无障碍、Zod校验、Server组件、Room关系化

批次A:重命名 + 路由拆分
- store.ts → roomRepository.ts,更新全部 import
- blindbox/plan/route.ts 精简为薄路由,业务逻辑抽取到 planActions.ts / planQueries.ts

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

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

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

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

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

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

测试:全部329个用例通过,修复68个因auth mock缺失导致的测试失败
This commit is contained in:
2026-03-02 20:27:06 +08:00
parent 6bb0e65d4c
commit 4f4220652e
59 changed files with 2369 additions and 1999 deletions
+2 -1
View File
@@ -29,7 +29,8 @@
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"swr": "^2.4.0" "swr": "^2.4.0",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -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");
+56 -1
View File
@@ -9,13 +9,68 @@ generator client {
model Room { model Room {
id String @id id String @id
data String creatorId String
scene String @default("eat")
locked Boolean @default(false)
match String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
expiresAt DateTime expiresAt DateTime
members RoomMember[]
restaurants RoomRestaurant[]
likes RoomLike[]
swipes RoomSwipe[]
@@index([expiresAt]) @@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 { model User {
id String @id @default(cuid()) id String @id @default(cuid())
username String @unique username String @unique
+242
View File
@@ -0,0 +1,242 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowLeft,
Trophy,
Zap,
Gift,
ClipboardList,
Target,
TrendingUp,
BarChart3,
} from "lucide-react";
import RestaurantImage from "@/components/RestaurantImage";
import ContractHistoryItem from "@/components/ContractHistoryItem";
import EmptyState from "@/components/EmptyState";
import { Skeleton, RecordItemSkeleton } from "@/components/Skeleton";
import { buildNavUrl } from "@/lib/navigation";
import { useAchievements } from "@/hooks/useAchievements";
import type { Restaurant } from "@/types";
type Tab = "decisions" | "contracts";
function firstImage(r: Restaurant): string {
if (r.images?.length > 0) return r.images[0];
const legacy = (r as unknown as Record<string, unknown>).image;
return typeof legacy === "string" ? legacy : "";
}
export default function AchievementsPage({ initialUserId }: { initialUserId: string }) {
const router = useRouter();
const [tab, setTab] = useState<Tab>("decisions");
const [userId] = useState<string | undefined>(initialUserId || undefined);
const { stats, decisions, contracts, isLoading: loading } = useAchievements(userId);
const statCards = [
{
label: "决策记录",
value: stats.totalDecisions,
icon: Target,
color: "text-amber-400",
bg: "bg-amber-600/15",
},
{
label: "契约完成",
value: stats.completedContracts,
icon: Trophy,
color: "text-emerald-400",
bg: "bg-emerald-600/15",
},
{
label: "完成率",
value: stats.totalContracts > 0 ? `${stats.completionRate}%` : "—",
icon: TrendingUp,
color: "text-purple-400",
bg: "bg-purple-600/15",
},
];
const tabs: { id: Tab; label: string; icon: typeof Zap }[] = [
{ id: "decisions", label: "极速救场", icon: Zap },
{ id: "contracts", label: "周末契约", icon: Gift },
];
return (
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-6 overflow-y-auto scrollbar-none">
{/* Ambient glow */}
<div className="pointer-events-none fixed left-1/2 top-0 -translate-x-1/2 -translate-y-1/3 h-[320px] w-[320px] rounded-full bg-purple-500/8 blur-3xl" />
{/* Header */}
<div className="flex w-full max-w-sm items-center gap-3">
<button
onClick={() => router.back()}
aria-label="返回"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface ring-1 ring-border transition-colors active:bg-elevated"
>
<ArrowLeft size={16} className="text-muted" />
</button>
<div className="flex items-center gap-2">
<Trophy size={18} className="text-amber-400" />
<h1 className="text-base font-bold text-heading"></h1>
</div>
</div>
{/* Stats */}
<motion.div
className="mt-6 grid w-full max-w-sm grid-cols-3 gap-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
{statCards.map((s) => (
<div
key={s.label}
className="flex flex-col items-center gap-1.5 rounded-xl bg-surface p-3 ring-1 ring-border"
>
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${s.bg}`}>
<s.icon size={16} className={s.color} />
</div>
{loading ? (
<Skeleton className="h-5 w-10" />
) : (
<p className="text-lg font-black text-heading">{s.value}</p>
)}
<p className="text-[10px] text-muted">{s.label}</p>
</div>
))}
</motion.div>
{/* Tab switcher */}
<motion.div
className="mt-6 flex w-full max-w-sm rounded-xl bg-surface p-1 ring-1 ring-border"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`relative flex flex-1 items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-semibold transition-colors ${
tab === t.id ? "text-heading" : "text-muted"
}`}
>
{tab === t.id && (
<motion.div
layoutId="activeTab"
className="absolute inset-0 rounded-lg bg-elevated ring-1 ring-border"
transition={{ type: "spring", damping: 25, stiffness: 350 }}
/>
)}
<t.icon size={13} className="relative z-10" />
<span className="relative z-10">{t.label}</span>
</button>
))}
</motion.div>
{/* Content */}
<div className="mt-4 w-full max-w-sm">
<AnimatePresence mode="wait">
{tab === "decisions" && (
<motion.div
key="decisions"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.2 }}
className="flex flex-col gap-2"
>
{loading ? (
<>
<RecordItemSkeleton />
<RecordItemSkeleton />
<RecordItemSkeleton />
</>
) : decisions.length === 0 ? (
<EmptyState
icon={ClipboardList}
image="/empty-no-record.png"
title="还没有决策记录"
subtitle="使用极速救场后会在这里记录"
color="amber"
/>
) : (
decisions.map((d) => (
<a
key={d.id}
href={buildNavUrl(d.restaurantData)}
target="_blank"
rel="noopener noreferrer"
className="flex gap-3 rounded-xl bg-elevated p-2.5 transition-colors active:bg-subtle"
>
{firstImage(d.restaurantData) && (
<RestaurantImage
src={firstImage(d.restaurantData)}
alt={d.restaurantName}
className="h-12 w-12 shrink-0 rounded-lg object-cover"
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center">
<p className="truncate text-sm font-semibold text-heading">
{d.restaurantName}
</p>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted">
<span>
{d.matchType === "unanimous" ? "全员一致" : "最佳匹配"}
</span>
<span>{d.participants} </span>
<span>
{new Date(d.createdAt).toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
})}
</span>
</div>
</div>
</a>
))
)}
</motion.div>
)}
{tab === "contracts" && (
<motion.div
key="contracts"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
className="flex flex-col gap-2"
>
{loading ? (
<>
<RecordItemSkeleton />
<RecordItemSkeleton />
<RecordItemSkeleton />
</>
) : contracts.length === 0 ? (
<EmptyState
icon={BarChart3}
image="/empty-no-room.png"
title="还没有契约记录"
subtitle="完成或过期的契约会在这里显示"
color="purple"
/>
) : (
contracts.map((c) => (
<ContractHistoryItem key={c.id} record={c} />
))
)}
</motion.div>
)}
</AnimatePresence>
</div>
<div className="h-8 shrink-0" />
</div>
);
}
+16 -248
View File
@@ -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"; export default async function AchievementsPage() {
import { useRouter } from "next/navigation"; const cookieStore = await cookies();
import { motion, AnimatePresence } from "framer-motion"; const token = cookieStore.get("nw_token")?.value;
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";
type Tab = "decisions" | "contracts"; if (!token) redirect("/");
function firstImage(r: Restaurant): string { let userId: string;
if (r.images?.length > 0) return r.images[0]; try {
const legacy = (r as unknown as Record<string, unknown>).image; userId = await verifyToken(token);
return typeof legacy === "string" ? legacy : ""; } catch {
} redirect("/");
}
export default function AchievementsPage() {
const router = useRouter(); return <AchievementsClient initialUserId={userId} />;
const [tab, setTab] = useState<Tab>("decisions");
const [userId, setUserId] = useState<string | undefined>(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 (
<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>
);
} }
+8 -5
View File
@@ -1,7 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; 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", () => ({ vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}), requireMembership: vi.fn().mockResolvedValue({}),
@@ -36,7 +39,7 @@ describe("POST /api/blindbox/draw", () => {
const req = createRequest("/api/blindbox/draw", { const req = createRequest("/api/blindbox/draw", {
method: "POST", method: "POST",
body: { roomId: "bb-room-1", userId: "user-1" }, body: { roomId: "bb-room-1" },
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
@@ -62,7 +65,7 @@ describe("POST /api/blindbox/draw", () => {
const req = createRequest("/api/blindbox/draw", { const req = createRequest("/api/blindbox/draw", {
method: "POST", method: "POST",
body: { roomId: "bb-room-1", userId: "user-1" }, body: { roomId: "bb-room-1" },
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
expect(res.status).toBe(404); expect(res.status).toBe(404);
@@ -82,7 +85,7 @@ describe("POST /api/blindbox/draw", () => {
const req = createRequest("/api/blindbox/draw", { const req = createRequest("/api/blindbox/draw", {
method: "POST", method: "POST",
body: { roomId: "bb-room-1", userId: "user-1" }, body: { roomId: "bb-room-1" },
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
expect(res.status).toBe(409); expect(res.status).toBe(409);
@@ -91,7 +94,7 @@ describe("POST /api/blindbox/draw", () => {
it("returns 400 when roomId is missing", async () => { it("returns 400 when roomId is missing", async () => {
const req = createRequest("/api/blindbox/draw", { const req = createRequest("/api/blindbox/draw", {
method: "POST", method: "POST",
body: { userId: "user-1" }, body: {},
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
expect(res.status).toBe(400); expect(res.status).toBe(400);
+13 -12
View File
@@ -3,6 +3,10 @@ import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_WEEKEND_PLAN } from "@/__tests__/helpers/fixtures"; import { TEST_WEEKEND_PLAN } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({ vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}), requireMembership: vi.fn().mockResolvedValue({}),
})); }));
@@ -29,7 +33,6 @@ describe("POST /api/blindbox/plan", () => {
method: "POST", method: "POST",
body: { body: {
roomId: "bb-room-1", roomId: "bb-room-1",
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
}, },
}); });
@@ -45,7 +48,6 @@ describe("POST /api/blindbox/plan", () => {
method: "POST", method: "POST",
body: { body: {
roomId: "bb-room-1", roomId: "bb-room-1",
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 }, availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 },
}, },
}); });
@@ -57,7 +59,6 @@ describe("POST /api/blindbox/plan", () => {
const req = createRequest("/api/blindbox/plan", { const req = createRequest("/api/blindbox/plan", {
method: "POST", method: "POST",
body: { body: {
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 },
}, },
}); });
@@ -76,7 +77,7 @@ describe("PATCH /api/blindbox/plan", () => {
const req = createRequest("/api/blindbox/plan", { const req = createRequest("/api/blindbox/plan", {
method: "PATCH", method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "accept" }, body: { planId: "plan-1", action: "accept" },
}); });
const res = await PATCH(req, mockCtx); const res = await PATCH(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
@@ -94,7 +95,7 @@ describe("PATCH /api/blindbox/plan", () => {
const req = createRequest("/api/blindbox/plan", { const req = createRequest("/api/blindbox/plan", {
method: "PATCH", method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "complete" }, body: { planId: "plan-1", action: "complete" },
}); });
const res = await PATCH(req, mockCtx); const res = await PATCH(req, mockCtx);
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -109,7 +110,7 @@ describe("PATCH /api/blindbox/plan", () => {
const req = createRequest("/api/blindbox/plan", { const req = createRequest("/api/blindbox/plan", {
method: "PATCH", method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "expire" }, body: { planId: "plan-1", action: "expire" },
}); });
const res = await PATCH(req, mockCtx); const res = await PATCH(req, mockCtx);
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -123,7 +124,7 @@ describe("PATCH /api/blindbox/plan", () => {
const req = createRequest("/api/blindbox/plan", { const req = createRequest("/api/blindbox/plan", {
method: "PATCH", method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "accept" }, body: { planId: "plan-1", action: "accept" },
}); });
const res = await PATCH(req, mockCtx); const res = await PATCH(req, mockCtx);
expect(res.status).toBe(400); expect(res.status).toBe(400);
@@ -137,7 +138,7 @@ describe("PATCH /api/blindbox/plan", () => {
const req = createRequest("/api/blindbox/plan", { const req = createRequest("/api/blindbox/plan", {
method: "PATCH", method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "accept" }, body: { planId: "plan-1", action: "accept" },
}); });
const res = await PATCH(req, mockCtx); const res = await PATCH(req, mockCtx);
expect(res.status).toBe(403); expect(res.status).toBe(403);
@@ -148,7 +149,7 @@ describe("PATCH /api/blindbox/plan", () => {
const req = createRequest("/api/blindbox/plan", { const req = createRequest("/api/blindbox/plan", {
method: "PATCH", method: "PATCH",
body: { planId: "plan-1", userId: "user-1", action: "invalid" }, body: { planId: "plan-1", action: "invalid" },
}); });
const res = await PATCH(req, mockCtx); const res = await PATCH(req, mockCtx);
expect(res.status).toBe(400); expect(res.status).toBe(400);
@@ -164,7 +165,7 @@ describe("GET /api/blindbox/plan", () => {
createdAt: new Date(), createdAt: new Date(),
} as never); } 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 res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
@@ -176,7 +177,7 @@ describe("GET /api/blindbox/plan", () => {
it("returns null when no plan found", async () => { it("returns null when no plan found", async () => {
prismaMock.weekendPlan.findFirst.mockResolvedValue(null as never); 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 res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res); const { data } = await parseJsonResponse(res);
@@ -184,7 +185,7 @@ describe("GET /api/blindbox/plan", () => {
}); });
it("returns 400 for invalid mode", async () => { 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); const res = await GET(req, mockCtx);
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
+7 -163
View File
@@ -3,6 +3,8 @@ import { requireMembership } from "@/lib/blindbox";
import { apiHandler, ApiError } from "@/lib/api"; import { apiHandler, ApiError } from "@/lib/api";
import { runPlanGeneration } from "@/lib/blindboxPlanGen"; import { runPlanGeneration } from "@/lib/blindboxPlanGen";
import { getAuthUserId } from "@/lib/auth"; import { getAuthUserId } from "@/lib/auth";
import { handlePlanUpdate } from "@/lib/planActions";
import { getLatestPlan, getPendingPlans, getHistoryPlans } from "@/lib/planQueries";
interface AvailableTime { interface AvailableTime {
date: string; 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) => { export const PATCH = apiHandler(async (req) => {
const userId = await getAuthUserId(req); const userId = await getAuthUserId(req);
const { planId, action, days } = await req.json(); const { planId, action, days } = await req.json();
if (!planId) throw new ApiError("planId 不能为空"); if (!planId) throw new ApiError("planId 不能为空");
const { prisma } = await import("@/lib/prisma"); const result = await handlePlanUpdate(planId, userId, action, days);
const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } }); return NextResponse.json(result);
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);
}); });
export const GET = apiHandler(async (req) => { export const GET = apiHandler(async (req) => {
@@ -135,92 +53,18 @@ export const GET = apiHandler(async (req) => {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const mode = searchParams.get("mode") || "latest"; const mode = searchParams.get("mode") || "latest";
const { prisma } = await import("@/lib/prisma");
if (mode === "latest") { if (mode === "latest") {
const roomId = searchParams.get("roomId"); const roomId = searchParams.get("roomId");
if (!roomId) throw new ApiError("roomId 不能为空"); if (!roomId) throw new ApiError("roomId 不能为空");
return NextResponse.json(await getLatestPlan(roomId, userId));
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 },
});
} }
if (mode === "pending") { if (mode === "pending") {
const plans = await prisma.weekendPlan.findMany({ return NextResponse.json(await getPendingPlans(userId));
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 });
} }
if (mode === "history") { if (mode === "history") {
const plans = await prisma.weekendPlan.findMany({ return NextResponse.json(await getHistoryPlans(userId));
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 });
} }
throw new ApiError("无效的 mode 参数", 400); throw new ApiError("无效的 mode 参数", 400);
+15 -18
View File
@@ -2,15 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/prisma", () => ({ prisma: {} })); vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/blindbox", () => ({ vi.mock("@/lib/auth", () => ({
requireMembership: vi.fn().mockResolvedValue({}), getAuthUserId: vi.fn().mockResolvedValue("user-1"),
})); }));
vi.mock("@/lib/api", () => ({ vi.mock("@/lib/blindbox", () => ({
requireUserId: vi.fn((v) => { requireMembership: vi.fn().mockResolvedValue({}),
if (!v || typeof v !== "string") throw new Error("请先登录");
return v;
}),
})); }));
vi.mock("@/lib/blindboxPlanGen", () => ({ vi.mock("@/lib/blindboxPlanGen", () => ({
@@ -19,11 +16,14 @@ vi.mock("@/lib/blindboxPlanGen", () => ({
import { POST } from "./route"; import { POST } from "./route";
import { runPlanGeneration } from "@/lib/blindboxPlanGen"; import { runPlanGeneration } from "@/lib/blindboxPlanGen";
import { getAuthUserId } from "@/lib/auth";
import { ApiError } from "@/lib/api";
const mockRunPlan = vi.mocked(runPlanGeneration); const mockRunPlan = vi.mocked(runPlanGeneration);
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
}); });
async function readStream(response: Response): Promise<string> { async function readStream(response: Response): Promise<string> {
@@ -55,12 +55,11 @@ describe("POST /api/blindbox/plan/stream", () => {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
roomId: "bb-room-1", roomId: "bb-room-1",
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, 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.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("text/event-stream"); 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" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
roomId: "bb-room-1", roomId: "bb-room-1",
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, 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); const text = await readStream(res);
expect(text).toContain("event: error"); expect(text).toContain("event: error");
expect(text).toContain("AI 服务不可用"); expect(text).toContain("AI 服务不可用");
@@ -95,12 +93,11 @@ describe("POST /api/blindbox/plan/stream", () => {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, 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); expect(res.status).toBe(400);
}); });
@@ -110,16 +107,16 @@ describe("POST /api/blindbox/plan/stream", () => {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
roomId: "bb-room-1", roomId: "bb-room-1",
userId: "user-1",
availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 }, 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); 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", { const req = new Request("http://localhost/api/blindbox/plan/stream", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -129,7 +126,7 @@ describe("POST /api/blindbox/plan/stream", () => {
}), }),
}); });
const res = await POST(req); const res = await POST(req as never);
expect(res.status).toBe(400); expect(res.status).toBe(401);
}); });
}); });
+21 -1
View File
@@ -49,13 +49,33 @@ export async function POST(req: NextRequest): Promise<Response> {
}); });
} }
const signal = req.signal;
const stream = new ReadableStream({ const stream = new ReadableStream({
async start(controller) { async start(controller) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
let closed = false;
const push = (event: string, data: string) => { const push = (event: string, data: string) => {
if (closed) return;
controller.enqueue(encoder.encode(encodeSSE(event, data))); 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 { try {
const result = await runPlanGeneration(roomId, userId, availableTime, (message) => { const result = await runPlanGeneration(roomId, userId, availableTime, (message) => {
push("status", message); push("status", message);
@@ -65,7 +85,7 @@ export async function POST(req: NextRequest): Promise<Response> {
const message = e instanceof Error ? e.message : "生成计划失败"; const message = e instanceof Error ? e.message : "生成计划失败";
push("error", message); push("error", message);
} finally { } finally {
controller.close(); cleanup();
} }
}, },
}); });
+11 -3
View File
@@ -2,6 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_BLINDBOX_IDEA } from "@/__tests__/helpers/fixtures"; 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", () => ({ vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}), requireMembership: vi.fn().mockResolvedValue({}),
@@ -21,11 +26,13 @@ vi.mock("@/lib/ai", () => ({
})); }));
import { POST } from "./route"; import { POST } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) }; const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => { beforeEach(() => {
resetPrismaMock(); resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
}); });
describe("POST /api/blindbox/retag", () => { describe("POST /api/blindbox/retag", () => {
@@ -36,7 +43,7 @@ describe("POST /api/blindbox/retag", () => {
const req = createRequest("/api/blindbox/retag", { const req = createRequest("/api/blindbox/retag", {
method: "POST", method: "POST",
body: { roomId: "bb-room-1", userId: "user-1" }, body: { roomId: "bb-room-1" },
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
@@ -51,7 +58,7 @@ describe("POST /api/blindbox/retag", () => {
const req = createRequest("/api/blindbox/retag", { const req = createRequest("/api/blindbox/retag", {
method: "POST", method: "POST",
body: { roomId: "bb-room-1", userId: "user-1" }, body: { roomId: "bb-room-1" },
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
@@ -60,7 +67,8 @@ describe("POST /api/blindbox/retag", () => {
expect(data.retagged).toBe(0); 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", { const req = createRequest("/api/blindbox/retag", {
method: "POST", method: "POST",
body: { roomId: "bb-room-1" }, body: { roomId: "bb-room-1" },
+16 -14
View File
@@ -3,6 +3,10 @@ import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_BLINDBOX_ROOM, TEST_USER } from "@/__tests__/helpers/fixtures"; import { TEST_BLINDBOX_ROOM, TEST_USER } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({ vi.mock("@/lib/blindbox", () => ({
getRoomByCode: vi.fn(), getRoomByCode: vi.fn(),
requireMembership: vi.fn().mockResolvedValue({}), requireMembership: vi.fn().mockResolvedValue({}),
@@ -10,12 +14,14 @@ vi.mock("@/lib/blindbox", () => ({
import { GET, PATCH, DELETE } from "./route"; import { GET, PATCH, DELETE } from "./route";
import { getRoomByCode } from "@/lib/blindbox"; import { getRoomByCode } from "@/lib/blindbox";
import { getAuthUserId } from "@/lib/auth";
const mockGetRoomByCode = vi.mocked(getRoomByCode); const mockGetRoomByCode = vi.mocked(getRoomByCode);
beforeEach(() => { beforeEach(() => {
resetPrismaMock(); resetPrismaMock();
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
}); });
describe("GET /api/blindbox/room/[code]", () => { describe("GET /api/blindbox/room/[code]", () => {
@@ -61,7 +67,7 @@ describe("PATCH /api/blindbox/room/[code]", () => {
const req = createRequest("/api/blindbox/room/ABC123", { const req = createRequest("/api/blindbox/room/ABC123", {
method: "PATCH", 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 ctx = createRouteContext({ code: "ABC123" });
const res = await PATCH(req, ctx); const res = await PATCH(req, ctx);
@@ -76,7 +82,7 @@ describe("PATCH /api/blindbox/room/[code]", () => {
const req = createRequest("/api/blindbox/room/ABC123", { const req = createRequest("/api/blindbox/room/ABC123", {
method: "PATCH", method: "PATCH",
body: { userId: "user-1", lat: 999, lng: 121.4 }, body: { lat: 999, lng: 121.4 },
}); });
const ctx = createRouteContext({ code: "ABC123" }); const ctx = createRouteContext({ code: "ABC123" });
const res = await PATCH(req, ctx); const res = await PATCH(req, ctx);
@@ -86,13 +92,11 @@ describe("PATCH /api/blindbox/room/[code]", () => {
describe("DELETE /api/blindbox/room/[code]", () => { describe("DELETE /api/blindbox/room/[code]", () => {
it("deletes room when creator", async () => { 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.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
prismaMock.blindBoxRoom.delete.mockResolvedValue({} as never); prismaMock.blindBoxRoom.delete.mockResolvedValue({} as never);
const req = createRequest("/api/blindbox/room/ABC123", { const req = createRequest("/api/blindbox/room/ABC123", { method: "DELETE", body: {} });
method: "DELETE",
body: { userId: "user-1" },
});
const ctx = createRouteContext({ code: "ABC123" }); const ctx = createRouteContext({ code: "ABC123" });
const res = await DELETE(req, ctx); const res = await DELETE(req, ctx);
const { data } = await parseJsonResponse(res); const { data } = await parseJsonResponse(res);
@@ -101,14 +105,13 @@ describe("DELETE /api/blindbox/room/[code]", () => {
}); });
it("leaves room when not creator", async () => { 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.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
prismaMock.blindBoxMember.findUnique.mockResolvedValue({ id: "member-2" } as never); prismaMock.blindBoxMember.findUnique.mockResolvedValue({ id: "member-2" } as never);
prismaMock.blindBoxMember.delete.mockResolvedValue({} as never); prismaMock.blindBoxMember.delete.mockResolvedValue({} as never);
const req = createRequest("/api/blindbox/room/ABC123", { const req = createRequest("/api/blindbox/room/ABC123", { method: "DELETE", body: {} });
method: "DELETE",
body: { userId: "user-2" },
});
const ctx = createRouteContext({ code: "ABC123" }); const ctx = createRouteContext({ code: "ABC123" });
const res = await DELETE(req, ctx); const res = await DELETE(req, ctx);
const { data } = await parseJsonResponse(res); 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 () => { 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.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never);
prismaMock.blindBoxMember.findUnique.mockResolvedValue(null as never); prismaMock.blindBoxMember.findUnique.mockResolvedValue(null as never);
const req = createRequest("/api/blindbox/room/ABC123", { const req = createRequest("/api/blindbox/room/ABC123", { method: "DELETE", body: {} });
method: "DELETE",
body: { userId: "stranger" },
});
const ctx = createRouteContext({ code: "ABC123" }); const ctx = createRouteContext({ code: "ABC123" });
const res = await DELETE(req, ctx); const res = await DELETE(req, ctx);
expect(res.status).toBe(403); expect(res.status).toBe(403);
+8 -4
View File
@@ -3,6 +3,10 @@ import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures"; import { TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
import { POST } from "./route"; import { POST } from "./route";
const mockCtx = { params: Promise.resolve({}) }; const mockCtx = { params: Promise.resolve({}) };
@@ -19,7 +23,7 @@ describe("POST /api/blindbox/room/join", () => {
const req = createRequest("/api/blindbox/room/join", { const req = createRequest("/api/blindbox/room/join", {
method: "POST", method: "POST",
body: { userId: "user-2", code: "ABC123" }, body: { code: "ABC123" },
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
@@ -34,7 +38,7 @@ describe("POST /api/blindbox/room/join", () => {
const req = createRequest("/api/blindbox/room/join", { const req = createRequest("/api/blindbox/room/join", {
method: "POST", method: "POST",
body: { userId: "user-1", code: "ABC123" }, body: { code: "ABC123" },
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
const { data } = await parseJsonResponse(res); const { data } = await parseJsonResponse(res);
@@ -47,7 +51,7 @@ describe("POST /api/blindbox/room/join", () => {
const req = createRequest("/api/blindbox/room/join", { const req = createRequest("/api/blindbox/room/join", {
method: "POST", method: "POST",
body: { userId: "user-1", code: "BADCODE" }, body: { code: "BADCODE" },
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
expect(res.status).toBe(404); expect(res.status).toBe(404);
@@ -56,7 +60,7 @@ describe("POST /api/blindbox/room/join", () => {
it("returns 400 when code is missing", async () => { it("returns 400 when code is missing", async () => {
const req = createRequest("/api/blindbox/room/join", { const req = createRequest("/api/blindbox/room/join", {
method: "POST", method: "POST",
body: { userId: "user-1" }, body: {},
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
expect(res.status).toBe(400); expect(res.status).toBe(400);
+12 -4
View File
@@ -2,17 +2,24 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER, TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures"; 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", () => ({ vi.mock("@/lib/blindbox", () => ({
generateUniqueRoomCode: vi.fn().mockResolvedValue("XYZ789"), generateUniqueRoomCode: vi.fn().mockResolvedValue("XYZ789"),
})); }));
import { POST } from "./route"; import { POST } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) }; const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => { beforeEach(() => {
resetPrismaMock(); resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
}); });
describe("POST /api/blindbox/room", () => { describe("POST /api/blindbox/room", () => {
@@ -25,7 +32,7 @@ describe("POST /api/blindbox/room", () => {
const req = createRequest("/api/blindbox/room", { const req = createRequest("/api/blindbox/room", {
method: "POST", method: "POST",
body: { userId: "user-1", name: "周末计划" }, body: { name: "周末计划" },
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
@@ -40,13 +47,14 @@ describe("POST /api/blindbox/room", () => {
const req = createRequest("/api/blindbox/room", { const req = createRequest("/api/blindbox/room", {
method: "POST", method: "POST",
body: { userId: "user-1" }, body: {},
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
expect(res.status).toBe(201); 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", { const req = createRequest("/api/blindbox/room", {
method: "POST", method: "POST",
body: { name: "test" }, body: { name: "test" },
@@ -58,7 +66,7 @@ describe("POST /api/blindbox/room", () => {
it("returns 400 when room name too long", async () => { it("returns 400 when room name too long", async () => {
const req = createRequest("/api/blindbox/room", { const req = createRequest("/api/blindbox/room", {
method: "POST", method: "POST",
body: { userId: "user-1", name: "a".repeat(31) }, body: { name: "a".repeat(31) },
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
expect(res.status).toBe(400); expect(res.status).toBe(400);
+10 -2
View File
@@ -1,13 +1,20 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; 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 { GET } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) }; const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => { beforeEach(() => {
resetPrismaMock(); resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
}); });
describe("GET /api/blindbox/rooms", () => { describe("GET /api/blindbox/rooms", () => {
@@ -33,7 +40,7 @@ describe("GET /api/blindbox/rooms", () => {
{ roomId: "bb-room-1", _count: 3 }, { roomId: "bb-room-1", _count: 3 },
] as never); ] as never);
const req = createRequest("/api/blindbox/rooms?userId=user-1"); const req = createRequest("/api/blindbox/rooms");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
@@ -43,7 +50,8 @@ describe("GET /api/blindbox/rooms", () => {
expect(data.rooms[0].poolCount).toBe(3); 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 req = createRequest("/api/blindbox/rooms");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
expect(res.status).toBe(401); expect(res.status).toBe(401);
+19 -10
View File
@@ -2,6 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_BLINDBOX_IDEA } from "@/__tests__/helpers/fixtures"; 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", () => ({ vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}), requireMembership: vi.fn().mockResolvedValue({}),
@@ -21,11 +26,13 @@ vi.mock("@/lib/ai", () => ({
})); }));
import { POST, GET, PUT, DELETE } from "./route"; import { POST, GET, PUT, DELETE } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) }; const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => { beforeEach(() => {
resetPrismaMock(); resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
}); });
describe("POST /api/blindbox (create idea)", () => { describe("POST /api/blindbox (create idea)", () => {
@@ -35,7 +42,7 @@ describe("POST /api/blindbox (create idea)", () => {
const req = createRequest("/api/blindbox", { const req = createRequest("/api/blindbox", {
method: "POST", method: "POST",
body: { roomId: "bb-room-1", userId: "user-1", content: "去公园野餐" }, body: { roomId: "bb-room-1", content: "去公园野餐" },
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
@@ -44,7 +51,8 @@ describe("POST /api/blindbox (create idea)", () => {
expect(data.id).toBe("idea-1"); 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", { const req = createRequest("/api/blindbox", {
method: "POST", method: "POST",
body: { roomId: "bb-room-1", content: "test" }, 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 () => { it("returns 400 when content is empty", async () => {
const req = createRequest("/api/blindbox", { const req = createRequest("/api/blindbox", {
method: "POST", method: "POST",
body: { roomId: "bb-room-1", userId: "user-1", content: "" }, body: { roomId: "bb-room-1", content: "" },
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
expect(res.status).toBe(400); expect(res.status).toBe(400);
@@ -65,7 +73,7 @@ describe("POST /api/blindbox (create idea)", () => {
it("returns 400 when content over 200 chars", async () => { it("returns 400 when content over 200 chars", async () => {
const req = createRequest("/api/blindbox", { const req = createRequest("/api/blindbox", {
method: "POST", 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); const res = await POST(req, mockCtx);
expect(res.status).toBe(400); expect(res.status).toBe(400);
@@ -79,7 +87,7 @@ describe("GET /api/blindbox (get pool data)", () => {
.mockResolvedValueOnce([TEST_BLINDBOX_IDEA] as never) .mockResolvedValueOnce([TEST_BLINDBOX_IDEA] as never)
.mockResolvedValueOnce([] 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 res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
@@ -89,7 +97,8 @@ describe("GET /api/blindbox (get pool data)", () => {
expect(data.drawn).toHaveLength(0); 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 req = createRequest("/api/blindbox?roomId=bb-room-1");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
expect(res.status).toBe(401); expect(res.status).toBe(401);
@@ -102,7 +111,7 @@ describe("PUT /api/blindbox (edit idea)", () => {
const req = createRequest("/api/blindbox", { const req = createRequest("/api/blindbox", {
method: "PUT", method: "PUT",
body: { ideaId: "idea-1", userId: "user-1", content: "去公园散步" }, body: { ideaId: "idea-1", content: "去公园散步" },
}); });
const res = await PUT(req, mockCtx); const res = await PUT(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
@@ -116,7 +125,7 @@ describe("PUT /api/blindbox (edit idea)", () => {
const req = createRequest("/api/blindbox", { const req = createRequest("/api/blindbox", {
method: "PUT", method: "PUT",
body: { ideaId: "nonexistent", userId: "user-1", content: "test" }, body: { ideaId: "nonexistent", content: "test" },
}); });
const res = await PUT(req, mockCtx); const res = await PUT(req, mockCtx);
expect(res.status).toBe(404); expect(res.status).toBe(404);
@@ -129,7 +138,7 @@ describe("DELETE /api/blindbox (delete idea)", () => {
const req = createRequest("/api/blindbox", { const req = createRequest("/api/blindbox", {
method: "DELETE", method: "DELETE",
body: { ideaId: "idea-1", userId: "user-1" }, body: { ideaId: "idea-1" },
}); });
const res = await DELETE(req, mockCtx); const res = await DELETE(req, mockCtx);
const { data } = await parseJsonResponse(res); const { data } = await parseJsonResponse(res);
@@ -142,7 +151,7 @@ describe("DELETE /api/blindbox (delete idea)", () => {
const req = createRequest("/api/blindbox", { const req = createRequest("/api/blindbox", {
method: "DELETE", method: "DELETE",
body: { ideaId: "nonexistent", userId: "user-1" }, body: { ideaId: "nonexistent" },
}); });
const res = await DELETE(req, mockCtx); const res = await DELETE(req, mockCtx);
expect(res.status).toBe(404); expect(res.status).toBe(404);
@@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
vi.mock("@/lib/auth", () => ({
getAuthUserId: vi.fn().mockResolvedValue("user-1"),
}));
vi.mock("@/lib/blindbox", () => ({ vi.mock("@/lib/blindbox", () => ({
requireMembership: vi.fn().mockResolvedValue({}), requireMembership: vi.fn().mockResolvedValue({}),
})); }));
+1 -1
View File
@@ -1,5 +1,5 @@
import { buildRoomStatus } from "@/lib/buildRoomStatus"; import { buildRoomStatus } from "@/lib/buildRoomStatus";
import { getRoomData } from "@/lib/store"; import { getRoomData } from "@/lib/roomRepository";
import { subscribe } from "@/lib/roomEvents"; import { subscribe } from "@/lib/roomEvents";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
+2 -2
View File
@@ -4,7 +4,7 @@ import { TEST_ROOM_DATA } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/prisma", () => ({ prisma: {} })); vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/store", () => ({ vi.mock("@/lib/roomRepository", () => ({
atomicUpdateRoom: vi.fn(), atomicUpdateRoom: vi.fn(),
})); }));
@@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({
})); }));
import { POST } from "./route"; import { POST } from "./route";
import { atomicUpdateRoom } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/roomRepository";
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom); const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/roomRepository";
import { notify } from "@/lib/roomEvents"; import { notify } from "@/lib/roomEvents";
import { apiHandler, ApiError, requireUserId } from "@/lib/api"; import { apiHandler, ApiError, requireUserId } from "@/lib/api";
+2 -2
View File
@@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/prisma", () => ({ prisma: {} })); vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/store", () => ({ vi.mock("@/lib/roomRepository", () => ({
atomicUpdateRoom: vi.fn(), atomicUpdateRoom: vi.fn(),
})); }));
@@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({
})); }));
import { POST } from "./route"; import { POST } from "./route";
import { atomicUpdateRoom } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/roomRepository";
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom); const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/roomRepository";
import { notify } from "@/lib/roomEvents"; import { notify } from "@/lib/roomEvents";
import { apiHandler, ApiError, requireUserId } from "@/lib/api"; import { apiHandler, ApiError, requireUserId } from "@/lib/api";
+2 -2
View File
@@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT, TEST_RESTAURANT_2 } from "@/__tests__/
vi.mock("@/lib/prisma", () => ({ prisma: {} })); vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/store", () => ({ vi.mock("@/lib/roomRepository", () => ({
atomicUpdateRoom: vi.fn(), atomicUpdateRoom: vi.fn(),
})); }));
@@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({
})); }));
import { POST } from "./route"; import { POST } from "./route";
import { atomicUpdateRoom } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/roomRepository";
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom); const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/roomRepository";
import { notify } from "@/lib/roomEvents"; import { notify } from "@/lib/roomEvents";
import { apiHandler, ApiError, requireUserId } from "@/lib/api"; import { apiHandler, ApiError, requireUserId } from "@/lib/api";
+2 -2
View File
@@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/prisma", () => ({ prisma: {} })); vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/store", () => ({ vi.mock("@/lib/roomRepository", () => ({
atomicUpdateRoom: vi.fn(), atomicUpdateRoom: vi.fn(),
})); }));
@@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({
})); }));
import { POST } from "./route"; import { POST } from "./route";
import { atomicUpdateRoom } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/roomRepository";
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom); const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/roomRepository";
import { notify } from "@/lib/roomEvents"; import { notify } from "@/lib/roomEvents";
import { apiHandler, ApiError, requireUserId } from "@/lib/api"; import { apiHandler, ApiError, requireUserId } from "@/lib/api";
+2 -2
View File
@@ -4,7 +4,7 @@ import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/prisma", () => ({ prisma: {} })); vi.mock("@/lib/prisma", () => ({ prisma: {} }));
vi.mock("@/lib/store", () => ({ vi.mock("@/lib/roomRepository", () => ({
atomicUpdateRoom: vi.fn(), atomicUpdateRoom: vi.fn(),
})); }));
@@ -13,7 +13,7 @@ vi.mock("@/lib/roomEvents", () => ({
})); }));
import { POST } from "./route"; import { POST } from "./route";
import { atomicUpdateRoom } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/roomRepository";
const mockAtomicUpdate = vi.mocked(atomicUpdateRoom); const mockAtomicUpdate = vi.mocked(atomicUpdateRoom);
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { atomicUpdateRoom } from "@/lib/store"; import { atomicUpdateRoom } from "@/lib/roomRepository";
import { notify } from "@/lib/roomEvents"; import { notify } from "@/lib/roomEvents";
import { apiHandler, ApiError, requireUserId } from "@/lib/api"; import { apiHandler, ApiError, requireUserId } from "@/lib/api";
+2 -2
View File
@@ -5,7 +5,7 @@ vi.mock("@/lib/prisma", () => ({
prisma: {}, prisma: {},
})); }));
vi.mock("@/lib/store", () => ({ vi.mock("@/lib/roomRepository", () => ({
createRoom: vi.fn().mockResolvedValue("ROOM01"), createRoom: vi.fn().mockResolvedValue("ROOM01"),
})); }));
@@ -17,7 +17,7 @@ const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch); vi.stubGlobal("fetch", mockFetch);
import { POST } from "./route"; import { POST } from "./route";
import { createRoom } from "@/lib/store"; import { createRoom } from "@/lib/roomRepository";
const mockCtx = { params: Promise.resolve({}) }; const mockCtx = { params: Promise.resolve({}) };
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createRoom } from "@/lib/store"; import { createRoom } from "@/lib/roomRepository";
import { Restaurant, SceneType } from "@/types"; import { Restaurant, SceneType } from "@/types";
import { getSceneConfig } from "@/lib/sceneConfig"; import { getSceneConfig } from "@/lib/sceneConfig";
import { apiHandler, ApiError } from "@/lib/api"; import { apiHandler, ApiError } from "@/lib/api";
+10 -2
View File
@@ -2,17 +2,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_RESTAURANT } from "@/__tests__/helpers/fixtures"; 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 { GET } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) }; const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => { beforeEach(() => {
resetPrismaMock(); resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
}); });
describe("GET /api/user/achievements", () => { 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 req = createRequest("/api/user/achievements");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
expect(res.status).toBe(401); expect(res.status).toBe(401);
@@ -48,7 +56,7 @@ describe("GET /api/user/achievements", () => {
{ id: "bb-room-1", name: "周末", code: "ABC123" }, { id: "bb-room-1", name: "周末", code: "ABC123" },
] as never); ] as never);
const req = createRequest("/api/user/achievements?userId=user-1"); const req = createRequest("/api/user/achievements");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
+18 -9
View File
@@ -2,17 +2,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures"; 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 { GET, POST, DELETE } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) }; const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => { beforeEach(() => {
resetPrismaMock(); resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
}); });
describe("GET /api/user/favorite", () => { 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 req = createRequest("/api/user/favorite");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res); const { data } = await parseJsonResponse(res);
@@ -30,7 +38,7 @@ describe("GET /api/user/favorite", () => {
}, },
] as never); ] as never);
const req = createRequest("/api/user/favorite?userId=user-1"); const req = createRequest("/api/user/favorite");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
@@ -48,7 +56,7 @@ describe("POST /api/user/favorite", () => {
const req = createRequest("/api/user/favorite", { const req = createRequest("/api/user/favorite", {
method: "POST", method: "POST",
body: { userId: "user-1", restaurant: TEST_RESTAURANT }, body: { restaurant: TEST_RESTAURANT },
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
@@ -70,7 +78,7 @@ describe("POST /api/user/favorite", () => {
const req = createRequest("/api/user/favorite", { const req = createRequest("/api/user/favorite", {
method: "POST", method: "POST",
body: { userId: "user-1", restaurant: TEST_RESTAURANT }, body: { restaurant: TEST_RESTAURANT },
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
const { data } = await parseJsonResponse(res); const { data } = await parseJsonResponse(res);
@@ -79,7 +87,8 @@ describe("POST /api/user/favorite", () => {
expect(data.id).toBe("fav-existing"); 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", { const req = createRequest("/api/user/favorite", {
method: "POST", method: "POST",
body: { restaurant: TEST_RESTAURANT }, body: { restaurant: TEST_RESTAURANT },
@@ -91,7 +100,7 @@ describe("POST /api/user/favorite", () => {
it("returns 400 when no restaurant", async () => { it("returns 400 when no restaurant", async () => {
const req = createRequest("/api/user/favorite", { const req = createRequest("/api/user/favorite", {
method: "POST", method: "POST",
body: { userId: "user-1" }, body: {},
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
expect(res.status).toBe(400); expect(res.status).toBe(400);
@@ -108,7 +117,7 @@ describe("DELETE /api/user/favorite", () => {
const req = createRequest("/api/user/favorite", { const req = createRequest("/api/user/favorite", {
method: "DELETE", method: "DELETE",
body: { userId: "user-1", favoriteId: "fav-1" }, body: { favoriteId: "fav-1" },
}); });
const res = await DELETE(req, mockCtx); const res = await DELETE(req, mockCtx);
const { data } = await parseJsonResponse(res); const { data } = await parseJsonResponse(res);
@@ -120,7 +129,7 @@ describe("DELETE /api/user/favorite", () => {
const req = createRequest("/api/user/favorite", { const req = createRequest("/api/user/favorite", {
method: "DELETE", method: "DELETE",
body: { userId: "user-1", favoriteId: "nonexistent" }, body: { favoriteId: "nonexistent" },
}); });
const res = await DELETE(req, mockCtx); const res = await DELETE(req, mockCtx);
expect(res.status).toBe(404); expect(res.status).toBe(404);
@@ -134,7 +143,7 @@ describe("DELETE /api/user/favorite", () => {
const req = createRequest("/api/user/favorite", { const req = createRequest("/api/user/favorite", {
method: "DELETE", method: "DELETE",
body: { userId: "user-1", favoriteId: "fav-1" }, body: { favoriteId: "fav-1" },
}); });
const res = await DELETE(req, mockCtx); const res = await DELETE(req, mockCtx);
expect(res.status).toBe(404); expect(res.status).toBe(404);
+13 -7
View File
@@ -2,17 +2,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures"; 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 { GET, POST } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) }; const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => { beforeEach(() => {
resetPrismaMock(); resetPrismaMock();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
}); });
describe("GET /api/user/history", () => { 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 req = createRequest("/api/user/history");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
const { data } = await parseJsonResponse(res); const { data } = await parseJsonResponse(res);
@@ -33,7 +41,7 @@ describe("GET /api/user/history", () => {
}, },
] as never); ] as never);
const req = createRequest("/api/user/history?userId=user-1"); const req = createRequest("/api/user/history");
const res = await GET(req, mockCtx); const res = await GET(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
@@ -53,7 +61,6 @@ describe("POST /api/user/history", () => {
const req = createRequest("/api/user/history", { const req = createRequest("/api/user/history", {
method: "POST", method: "POST",
body: { body: {
userId: "user-1",
roomId: "room-1", roomId: "room-1",
restaurant: TEST_RESTAURANT, restaurant: TEST_RESTAURANT,
matchType: "unanimous", matchType: "unanimous",
@@ -74,7 +81,6 @@ describe("POST /api/user/history", () => {
const req = createRequest("/api/user/history", { const req = createRequest("/api/user/history", {
method: "POST", method: "POST",
body: { body: {
userId: "user-1",
roomId: "room-1", roomId: "room-1",
restaurant: TEST_RESTAURANT, restaurant: TEST_RESTAURANT,
matchType: "unanimous", matchType: "unanimous",
@@ -97,7 +103,6 @@ describe("POST /api/user/history", () => {
const req = createRequest("/api/user/history", { const req = createRequest("/api/user/history", {
method: "POST", method: "POST",
body: { body: {
userId: "user-1",
roomId: "room-1", roomId: "room-1",
restaurant: TEST_RESTAURANT, restaurant: TEST_RESTAURANT,
matchType: "best", matchType: "best",
@@ -111,13 +116,14 @@ describe("POST /api/user/history", () => {
it("returns 400 when missing required fields", async () => { it("returns 400 when missing required fields", async () => {
const req = createRequest("/api/user/history", { const req = createRequest("/api/user/history", {
method: "POST", method: "POST",
body: { userId: "user-1" }, body: {},
}); });
const res = await POST(req, mockCtx); const res = await POST(req, mockCtx);
expect(res.status).toBe(400); 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", { const req = createRequest("/api/user/history", {
method: "POST", method: "POST",
body: { roomId: "room-1", restaurant: TEST_RESTAURANT, matchType: "best" }, body: { roomId: "room-1", restaurant: TEST_RESTAURANT, matchType: "best" },
+17 -9
View File
@@ -2,6 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
import { TEST_USER } from "@/__tests__/helpers/fixtures"; 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", () => ({ vi.mock("bcryptjs", () => ({
default: { default: {
@@ -12,12 +17,14 @@ vi.mock("bcryptjs", () => ({
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { GET, PUT } from "./route"; import { GET, PUT } from "./route";
import { getAuthUserId } from "@/lib/auth";
const mockCtx = { params: Promise.resolve({}) }; const mockCtx = { params: Promise.resolve({}) };
beforeEach(() => { beforeEach(() => {
resetPrismaMock(); resetPrismaMock();
vi.mocked(bcrypt.compare).mockReset(); vi.mocked(bcrypt.compare).mockReset();
vi.mocked(getAuthUserId).mockResolvedValue("user-1");
}); });
describe("GET /api/user", () => { describe("GET /api/user", () => {
@@ -65,7 +72,7 @@ describe("PUT /api/user", () => {
const req = createRequest("/api/user", { const req = createRequest("/api/user", {
method: "PUT", method: "PUT",
body: { userId: "user-1", username: "newname" }, body: { username: "newname" },
}); });
const res = await PUT(req, mockCtx); const res = await PUT(req, mockCtx);
const { status, data } = await parseJsonResponse(res); const { status, data } = await parseJsonResponse(res);
@@ -81,7 +88,7 @@ describe("PUT /api/user", () => {
const req = createRequest("/api/user", { const req = createRequest("/api/user", {
method: "PUT", method: "PUT",
body: { userId: "user-1", username: "takenname" }, body: { username: "takenname" },
}); });
const res = await PUT(req, mockCtx); const res = await PUT(req, mockCtx);
expect(res.status).toBe(409); expect(res.status).toBe(409);
@@ -94,7 +101,7 @@ describe("PUT /api/user", () => {
const req = createRequest("/api/user", { const req = createRequest("/api/user", {
method: "PUT", method: "PUT",
body: { userId: "user-1", currentPassword: "old", newPassword: "newpass123" }, body: { currentPassword: "old", newPassword: "newpass123" },
}); });
const res = await PUT(req, mockCtx); const res = await PUT(req, mockCtx);
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -106,7 +113,7 @@ describe("PUT /api/user", () => {
const req = createRequest("/api/user", { const req = createRequest("/api/user", {
method: "PUT", method: "PUT",
body: { userId: "user-1", currentPassword: "wrong", newPassword: "newpass123" }, body: { currentPassword: "wrong", newPassword: "newpass123" },
}); });
const res = await PUT(req, mockCtx); const res = await PUT(req, mockCtx);
expect(res.status).toBe(403); expect(res.status).toBe(403);
@@ -117,13 +124,14 @@ describe("PUT /api/user", () => {
const req = createRequest("/api/user", { const req = createRequest("/api/user", {
method: "PUT", method: "PUT",
body: { userId: "user-1", newPassword: "newpass123" }, body: { newPassword: "newpass123" },
}); });
const res = await PUT(req, mockCtx); const res = await PUT(req, mockCtx);
expect(res.status).toBe(400); 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", { const req = createRequest("/api/user", {
method: "PUT", method: "PUT",
body: { username: "test" }, body: { username: "test" },
@@ -138,7 +146,7 @@ describe("PUT /api/user", () => {
const req = createRequest("/api/user", { const req = createRequest("/api/user", {
method: "PUT", method: "PUT",
body: { userId: "user-1", avatar: "🦊" }, body: { avatar: "🦊" },
}); });
const res = await PUT(req, mockCtx); const res = await PUT(req, mockCtx);
const { data } = await parseJsonResponse(res); const { data } = await parseJsonResponse(res);
@@ -155,7 +163,7 @@ describe("PUT /api/user", () => {
const req = createRequest("/api/user", { const req = createRequest("/api/user", {
method: "PUT", method: "PUT",
body: { userId: "user-1", email: "new@example.com" }, body: { email: "new@example.com" },
}); });
const res = await PUT(req, mockCtx); const res = await PUT(req, mockCtx);
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -166,7 +174,7 @@ describe("PUT /api/user", () => {
const req = createRequest("/api/user", { const req = createRequest("/api/user", {
method: "PUT", method: "PUT",
body: { userId: "user-1", email: "notanemail" }, body: { email: "notanemail" },
}); });
const res = await PUT(req, mockCtx); const res = await PUT(req, mockCtx);
expect(res.status).toBe(400); expect(res.status).toBe(400);
+501
View File
@@ -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 (
<div className="h-dvh bg-background pb-16 overflow-y-auto scrollbar-none">
<nav className="sticky top-0 z-10 flex h-14 items-center gap-3 bg-background/80 px-4 backdrop-blur-sm">
<div className="h-8 w-8" />
<h1 className="flex-1 text-base font-bold text-heading"></h1>
</nav>
<div className="mx-auto max-w-sm px-5">
<ProfileCardSkeleton />
<Card className="mt-4">
<div className="flex flex-col gap-2">
<RecordItemSkeleton />
<RecordItemSkeleton />
</div>
</Card>
<Card className="mt-4">
<div className="flex flex-col gap-2">
<RecordItemSkeleton />
<RecordItemSkeleton />
</div>
</Card>
</div>
</div>
);
}
if (!profile) return null;
return (
<div className="h-dvh bg-background pb-16 overflow-y-auto scrollbar-none">
<nav className="sticky top-0 z-10 flex h-14 items-center gap-3 bg-background/80 px-4 backdrop-blur-sm">
<button
onClick={() => router.push("/")}
className="flex h-8 w-8 items-center justify-center rounded-full text-muted transition-colors active:bg-elevated"
>
<ArrowLeft size={20} />
</button>
<h1 className="flex-1 text-base font-bold text-heading"></h1>
</nav>
<div className="mx-auto max-w-sm px-5">
{/* Profile card */}
<Card animated>
<div className="flex items-center gap-4">
<button
onClick={() => setEditingAvatar(!editingAvatar)}
className={`relative flex h-14 w-14 items-center justify-center rounded-2xl text-2xl transition-transform active:scale-95 ${getAvatarBg(profile.avatar)}`}
>
{profile.avatar}
<span className="absolute -bottom-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-surface text-muted shadow-sm ring-1 ring-border">
<Edit3 size={10} />
</span>
</button>
<div className="flex-1">
{editingUsername ? (
<div className="flex items-center gap-2">
<Input
type="text"
value={newUsername}
onChange={(e) => {
setNewUsername(e.target.value.slice(0, 16));
setUsernameMsg("");
}}
maxLength={16}
autoFocus
size="sm"
className="flex-1"
/>
<button
onClick={handleSaveUsername}
disabled={usernameSaving}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-white disabled:opacity-50"
>
{usernameSaving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
</button>
<button
onClick={() => { setEditingUsername(false); setUsernameMsg(""); }}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-elevated text-muted"
>
<X size={14} />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold text-heading">{profile.username}</h2>
<button
onClick={() => { setEditingUsername(true); setNewUsername(profile.username); }}
className="text-muted transition-colors active:text-secondary"
>
<Edit3 size={13} />
</button>
</div>
)}
{usernameMsg && <p className="mt-1 text-xs text-rose-400">{usernameMsg}</p>}
{(profile.decisionCount ?? 0) > 0 && (
<p className="mt-1 flex items-center gap-1 text-xs text-muted">
<Zap size={11} className="text-amber-400" />
{profile.decisionCount}
</p>
)}
</div>
</div>
{/* Avatar picker */}
<AnimatePresence>
{editingAvatar && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-4 grid grid-cols-6 gap-2">
{AVATARS.map((a) => (
<button
key={a.emoji}
onClick={() => handleSaveAvatar(a.emoji)}
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
profile.avatar === a.emoji
? `${a.bg} scale-110 ring-2 ring-accent ring-offset-1 ring-offset-surface`
: "bg-elevated hover:bg-subtle"
}`}
>
{a.emoji}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</Card>
{/* Change password */}
<Card animated className="mt-4" delay={0.05}>
<button
onClick={() => { setEditingPassword(!editingPassword); setPasswordMsg(""); }}
className="flex w-full items-center gap-2"
>
<Lock size={15} className="text-muted" />
<h3 className="text-sm font-semibold text-secondary"></h3>
</button>
<AnimatePresence>
{editingPassword && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-3 flex flex-col gap-3">
<div>
<p className="text-xs text-muted"></p>
<div className="relative mt-1">
<Input
type={showPassword ? "text" : "password"}
value={currentPassword}
onChange={(e) => { setCurrentPassword(e.target.value); setPasswordMsg(""); }}
className="pr-9"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted"
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
<div>
<p className="text-xs text-muted"></p>
<Input
type={showPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setPasswordMsg(""); }}
placeholder="至少 6 个字符"
className="mt-1"
/>
</div>
<div>
<p className="text-xs text-muted"></p>
<Input
type={showPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setPasswordMsg(""); }}
placeholder="再次输入新密码"
className="mt-1"
/>
</div>
{passwordMsg && (
<p className={`text-xs ${passwordMsg.includes("错误") || passwordMsg.includes("失败") || passwordMsg.includes("不一致") || passwordMsg.includes("至少") ? "text-rose-400" : "text-accent"}`}>
{passwordMsg}
</p>
)}
<button
onClick={handleSavePassword}
disabled={passwordSaving}
className="flex h-9 items-center justify-center gap-1.5 rounded-lg bg-accent text-xs font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{passwordSaving ? <Loader2 size={14} className="animate-spin" /> : "保存新密码"}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</Card>
{/* Email binding */}
<Card animated className="mt-4" delay={0.1}>
<div className="flex items-center gap-2">
<Mail size={15} className="text-muted" />
<h3 className="text-sm font-semibold text-secondary"></h3>
<span className="text-[10px] text-dim"></span>
</div>
<div className="mt-3 flex gap-2">
<Input
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setEmailMsg("");
}}
className="flex-1"
/>
<button
onClick={handleSaveEmail}
disabled={emailSaving}
className="flex h-9 items-center gap-1 rounded-lg bg-accent px-3 text-xs font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{emailSaving ? <Loader2 size={13} className="animate-spin" /> : "保存"}
</button>
</div>
{emailMsg && (
<p className={`mt-2 text-xs ${emailMsg.includes("失败") || emailMsg.includes("不正确") ? "text-rose-400" : "text-accent"}`}>
{emailMsg}
</p>
)}
</Card>
{/* Achievements link */}
<Card animated className="mt-4" delay={0.15}>
<button
onClick={() => router.push("/achievements")}
className="flex w-full items-center gap-2"
>
<Trophy size={15} className="text-amber-400" />
<h3 className="text-sm font-semibold text-secondary"></h3>
<span className="text-[10px] text-dim"> · </span>
<ChevronRight size={14} className="ml-auto text-muted" />
</button>
</Card>
<ProfileFavoritesCard
favorites={favorites}
loading={favLoading}
open={showFavorites}
onToggle={() => setShowFavorites((v) => !v)}
onRemove={handleRemoveFavorite}
onEmpty={() => router.push("/blindbox")}
delay={0.2}
/>
{/* Logout */}
<motion.div
className="mt-6 flex justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.25 }}
>
<button
onClick={handleLogout}
className="flex items-center justify-center gap-2 rounded-xl bg-surface px-6 py-2.5 text-sm font-medium text-rose-400/80 ring-1 ring-border transition-colors hover:bg-elevated hover:text-rose-400"
>
<LogOut size={14} />
退
</button>
</motion.div>
</div>
</div>
);
}
+2 -2
View File
@@ -33,7 +33,7 @@ vi.mock("@/components/ProfileFavoritesCard", () => ({
const mockFetch = vi.fn(); const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch); vi.stubGlobal("fetch", mockFetch);
import ProfilePage from "./page"; import ProfilePage from "./ProfileClient";
const toastCtx: ToastContextValue = { show: vi.fn() }; const toastCtx: ToastContextValue = { show: vi.fn() };
@@ -42,7 +42,7 @@ function renderPage() {
React.createElement( React.createElement(
ToastContext.Provider, ToastContext.Provider,
{ value: toastCtx }, { value: toastCtx },
React.createElement(ProfilePage), React.createElement(ProfilePage, { initialUserId: "user-1" }),
), ),
); );
} }
+14 -505
View File
@@ -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"; export default async function ProfilePage() {
import { useRouter } from "next/navigation"; const cookieStore = await cookies();
import { motion, AnimatePresence } from "framer-motion"; const token = cookieStore.get("nw_token")?.value;
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 function ProfilePage() { if (!token) redirect("/");
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);
const { favorites, isLoading: favLoading, mutate: mutateFavorites } = useFavorites(userId || undefined); let userId: string;
try {
const [editingUsername, setEditingUsername] = useState(false); userId = await verifyToken(token);
const [newUsername, setNewUsername] = useState(""); } catch {
const [usernameSaving, setUsernameSaving] = useState(false); redirect("/");
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 (
<div className="h-dvh bg-background pb-16 overflow-y-auto scrollbar-none">
<nav className="sticky top-0 z-10 flex h-14 items-center gap-3 bg-background/80 px-4 backdrop-blur-sm">
<div className="h-8 w-8" />
<h1 className="flex-1 text-base font-bold text-heading"></h1>
</nav>
<div className="mx-auto max-w-sm px-5">
<ProfileCardSkeleton />
<Card className="mt-4">
<div className="flex flex-col gap-2">
<RecordItemSkeleton />
<RecordItemSkeleton />
</div>
</Card>
<Card className="mt-4">
<div className="flex flex-col gap-2">
<RecordItemSkeleton />
<RecordItemSkeleton />
</div>
</Card>
</div>
</div>
);
} }
if (!profile) return null; return <ProfileClient initialUserId={userId} />;
return (
<div className="h-dvh bg-background pb-16 overflow-y-auto scrollbar-none">
<nav className="sticky top-0 z-10 flex h-14 items-center gap-3 bg-background/80 px-4 backdrop-blur-sm">
<button
onClick={() => router.push("/")}
className="flex h-8 w-8 items-center justify-center rounded-full text-muted transition-colors active:bg-elevated"
>
<ArrowLeft size={20} />
</button>
<h1 className="flex-1 text-base font-bold text-heading"></h1>
</nav>
<div className="mx-auto max-w-sm px-5">
{/* Profile card */}
<Card animated>
<div className="flex items-center gap-4">
<button
onClick={() => setEditingAvatar(!editingAvatar)}
className={`relative flex h-14 w-14 items-center justify-center rounded-2xl text-2xl transition-transform active:scale-95 ${getAvatarBg(profile.avatar)}`}
>
{profile.avatar}
<span className="absolute -bottom-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-surface text-muted shadow-sm ring-1 ring-border">
<Edit3 size={10} />
</span>
</button>
<div className="flex-1">
{editingUsername ? (
<div className="flex items-center gap-2">
<Input
type="text"
value={newUsername}
onChange={(e) => {
setNewUsername(e.target.value.slice(0, 16));
setUsernameMsg("");
}}
maxLength={16}
autoFocus
size="sm"
className="flex-1"
/>
<button
onClick={handleSaveUsername}
disabled={usernameSaving}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-white disabled:opacity-50"
>
{usernameSaving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
</button>
<button
onClick={() => { setEditingUsername(false); setUsernameMsg(""); }}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-elevated text-muted"
>
<X size={14} />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold text-heading">{profile.username}</h2>
<button
onClick={() => { setEditingUsername(true); setNewUsername(profile.username); }}
className="text-muted transition-colors active:text-secondary"
>
<Edit3 size={13} />
</button>
</div>
)}
{usernameMsg && <p className="mt-1 text-xs text-rose-400">{usernameMsg}</p>}
{(profile.decisionCount ?? 0) > 0 && (
<p className="mt-1 flex items-center gap-1 text-xs text-muted">
<Zap size={11} className="text-amber-400" />
{profile.decisionCount}
</p>
)}
</div>
</div>
{/* Avatar picker */}
<AnimatePresence>
{editingAvatar && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-4 grid grid-cols-6 gap-2">
{AVATARS.map((a) => (
<button
key={a.emoji}
onClick={() => handleSaveAvatar(a.emoji)}
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
profile.avatar === a.emoji
? `${a.bg} scale-110 ring-2 ring-accent ring-offset-1 ring-offset-surface`
: "bg-elevated hover:bg-subtle"
}`}
>
{a.emoji}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</Card>
{/* Change password */}
<Card animated className="mt-4" delay={0.05}>
<button
onClick={() => { setEditingPassword(!editingPassword); setPasswordMsg(""); }}
className="flex w-full items-center gap-2"
>
<Lock size={15} className="text-muted" />
<h3 className="text-sm font-semibold text-secondary"></h3>
</button>
<AnimatePresence>
{editingPassword && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-3 flex flex-col gap-3">
<div>
<p className="text-xs text-muted"></p>
<div className="relative mt-1">
<Input
type={showPassword ? "text" : "password"}
value={currentPassword}
onChange={(e) => { setCurrentPassword(e.target.value); setPasswordMsg(""); }}
className="pr-9"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted"
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
<div>
<p className="text-xs text-muted"></p>
<Input
type={showPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setPasswordMsg(""); }}
placeholder="至少 6 个字符"
className="mt-1"
/>
</div>
<div>
<p className="text-xs text-muted"></p>
<Input
type={showPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setPasswordMsg(""); }}
placeholder="再次输入新密码"
className="mt-1"
/>
</div>
{passwordMsg && (
<p className={`text-xs ${passwordMsg.includes("错误") || passwordMsg.includes("失败") || passwordMsg.includes("不一致") || passwordMsg.includes("至少") ? "text-rose-400" : "text-accent"}`}>
{passwordMsg}
</p>
)}
<button
onClick={handleSavePassword}
disabled={passwordSaving}
className="flex h-9 items-center justify-center gap-1.5 rounded-lg bg-accent text-xs font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{passwordSaving ? <Loader2 size={14} className="animate-spin" /> : "保存新密码"}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</Card>
{/* Email binding */}
<Card animated className="mt-4" delay={0.1}>
<div className="flex items-center gap-2">
<Mail size={15} className="text-muted" />
<h3 className="text-sm font-semibold text-secondary"></h3>
<span className="text-[10px] text-dim"></span>
</div>
<div className="mt-3 flex gap-2">
<Input
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setEmailMsg("");
}}
className="flex-1"
/>
<button
onClick={handleSaveEmail}
disabled={emailSaving}
className="flex h-9 items-center gap-1 rounded-lg bg-accent px-3 text-xs font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{emailSaving ? <Loader2 size={13} className="animate-spin" /> : "保存"}
</button>
</div>
{emailMsg && (
<p className={`mt-2 text-xs ${emailMsg.includes("失败") || emailMsg.includes("不正确") ? "text-rose-400" : "text-accent"}`}>
{emailMsg}
</p>
)}
</Card>
{/* Achievements link */}
<Card animated className="mt-4" delay={0.15}>
<button
onClick={() => router.push("/achievements")}
className="flex w-full items-center gap-2"
>
<Trophy size={15} className="text-amber-400" />
<h3 className="text-sm font-semibold text-secondary"></h3>
<span className="text-[10px] text-dim"> · </span>
<ChevronRight size={14} className="ml-auto text-muted" />
</button>
</Card>
<ProfileFavoritesCard
favorites={favorites}
loading={favLoading}
open={showFavorites}
onToggle={() => setShowFavorites((v) => !v)}
onRemove={handleRemoveFavorite}
onEmpty={() => router.push("/blindbox")}
delay={0.2}
/>
{/* Logout */}
<motion.div
className="mt-6 flex justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.25 }}
>
<button
onClick={handleLogout}
className="flex items-center justify-center gap-2 rounded-xl bg-surface px-6 py-2.5 text-sm font-medium text-rose-400/80 ring-1 ring-border transition-colors hover:bg-elevated hover:text-rose-400"
>
<LogOut size={14} />
退
</button>
</motion.div>
</div>
</div>
);
} }
+6 -3
View File
@@ -121,21 +121,24 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login"
}; };
return ( return (
<Modal open={open} onClose={onClose}> <Modal open={open} onClose={onClose} titleId="auth-modal-title">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<span className="text-lg font-bold text-heading"></span> <span id="auth-modal-title" className="text-lg font-bold text-heading"></span>
<button <button
onClick={onClose} onClick={onClose}
aria-label="关闭"
className="flex h-8 w-8 items-center justify-center rounded-full bg-elevated text-muted transition-colors active:bg-subtle" className="flex h-8 w-8 items-center justify-center rounded-full bg-elevated text-muted transition-colors active:bg-subtle"
> >
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
<div className="flex gap-1 rounded-xl bg-elevated p-1"> <div role="tablist" className="flex gap-1 rounded-xl bg-elevated p-1">
{(["login", "register"] as const).map((t) => ( {(["login", "register"] as const).map((t) => (
<button <button
key={t} key={t}
role="tab"
aria-selected={tab === t}
onClick={() => switchTab(t)} onClick={() => switchTab(t)}
className={`relative flex-1 rounded-lg py-2 text-sm font-semibold transition-colors ${ className={`relative flex-1 rounded-lg py-2 text-sm font-semibold transition-colors ${
tab === t ? "text-heading" : "text-muted" tab === t ? "text-heading" : "text-muted"
+2
View File
@@ -160,6 +160,8 @@ export default function BlindboxPlan({
<button <button
key={day.date} key={day.date}
onClick={() => setDayIndex(i)} onClick={() => setDayIndex(i)}
aria-label={day.date}
aria-pressed={i === dayIndex}
className={`rounded-full transition-all ${ className={`rounded-full transition-all ${
i === dayIndex i === dayIndex
? "h-1.5 w-5 bg-purple-400" ? "h-1.5 w-5 bg-purple-400"
+6
View File
@@ -10,6 +10,8 @@ interface ModalProps {
onClose: () => void; onClose: () => void;
children: React.ReactNode; children: React.ReactNode;
variant?: ModalVariant; variant?: ModalVariant;
/** Used for aria-labelledby; the element with this id should contain the dialog title */
titleId?: string;
} }
const sheet = { const sheet = {
@@ -41,6 +43,7 @@ export default function Modal({
onClose, onClose,
children, children,
variant = "sheet", variant = "sheet",
titleId,
}: ModalProps) { }: ModalProps) {
const backdropRef = useRef<HTMLDivElement>(null); const backdropRef = useRef<HTMLDivElement>(null);
const v = variants[variant]; const v = variants[variant];
@@ -60,6 +63,9 @@ export default function Modal({
}} }}
> >
<motion.div <motion.div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={v.content} className={v.content}
initial={v.initial} initial={v.initial}
animate={v.animate} animate={v.animate}
+1 -1
View File
@@ -70,7 +70,7 @@ export default function PlanItemEditModal({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-heading"></h3> <h3 className="text-sm font-bold text-heading"></h3>
<button onClick={handleClose} className="text-muted active:text-foreground"> <button onClick={handleClose} aria-label="关闭" className="text-muted active:text-foreground">
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
+1
View File
@@ -43,6 +43,7 @@ export default function QrInviteModal({
<Modal open={open} onClose={onClose}> <Modal open={open} onClose={onClose}>
<button <button
onClick={onClose} onClick={onClose}
aria-label="关闭"
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-elevated text-muted transition-colors active:bg-subtle" className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-elevated text-muted transition-colors active:bg-subtle"
> >
<X size={16} /> <X size={16} />
+22 -10
View File
@@ -5,6 +5,11 @@ import { useEffect, useRef } from "react";
import { fetcher, FetchError } from "@/lib/fetcher"; import { fetcher, FetchError } from "@/lib/fetcher";
import { RoomStatus } from "@/types"; import { RoomStatus } from "@/types";
// Exponential backoff with jitter: min(2000 * 2^attempt + rand(500), 60s)
function backoffMs(attempt: number): number {
return Math.min(2000 * Math.pow(2, attempt) + Math.random() * 500, 60_000);
}
export function useRoomPolling(roomId: string | undefined) { export function useRoomPolling(roomId: string | undefined) {
const { data, error, isLoading, mutate } = useSWR<RoomStatus>( const { data, error, isLoading, mutate } = useSWR<RoomStatus>(
roomId ? `/api/room/${roomId}` : null, roomId ? `/api/room/${roomId}` : null,
@@ -18,13 +23,21 @@ export function useRoomPolling(roomId: string | undefined) {
}, },
); );
const fallbackRef = useRef<ReturnType<typeof setInterval> | null>(null); const fallbackRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const attemptRef = useRef(0);
const notFound = error?.message === "NOT_FOUND"; const notFound = error?.message === "NOT_FOUND";
useEffect(() => { useEffect(() => {
if (!roomId) return; if (!roomId) return;
const es = new EventSource(`/api/room/${roomId}/events`); const es = new EventSource(`/api/room/${roomId}/events`);
const clearFallback = () => {
if (fallbackRef.current) {
clearTimeout(fallbackRef.current);
fallbackRef.current = null;
}
};
es.onmessage = (e) => { es.onmessage = (e) => {
try { try {
const parsed = JSON.parse(e.data); const parsed = JSON.parse(e.data);
@@ -38,23 +51,22 @@ export function useRoomPolling(roomId: string | undefined) {
es.onerror = () => { es.onerror = () => {
if (!fallbackRef.current) { if (!fallbackRef.current) {
fallbackRef.current = setInterval(() => mutate(), 2000); const delay = backoffMs(attemptRef.current++);
fallbackRef.current = setTimeout(() => {
mutate();
fallbackRef.current = null;
}, delay);
} }
}; };
es.onopen = () => { es.onopen = () => {
if (fallbackRef.current) { attemptRef.current = 0;
clearInterval(fallbackRef.current); clearFallback();
fallbackRef.current = null;
}
}; };
return () => { return () => {
es.close(); es.close();
if (fallbackRef.current) { clearFallback();
clearInterval(fallbackRef.current);
fallbackRef.current = null;
}
}; };
}, [roomId, mutate]); }, [roomId, mutate]);
+30 -74
View File
@@ -4,6 +4,13 @@ import type {
ChatCompletionTool, ChatCompletionTool,
} from "openai/resources/chat/completions"; } from "openai/resources/chat/completions";
import type { IdeaTags, PlanItem } from "@/types"; import type { IdeaTags, PlanItem } from "@/types";
import {
IdeaTagsSchema,
SuggestIdeasSchema,
GenerateScheduleSchema,
RefinePlanSchema,
SuggestAlternativesSchema,
} from "@/lib/schemas/ai";
function getClient() { function getClient() {
const apiKey = process.env.DEEPSEEK_API_KEY; const apiKey = process.env.DEEPSEEK_API_KEY;
@@ -101,37 +108,10 @@ export async function tagIdea(content: string): Promise<IdeaTags | null> {
const text = response.choices[0]?.message?.content; const text = response.choices[0]?.message?.content;
if (!text) return null; if (!text) return null;
const parsed = JSON.parse(text); const result = IdeaTagsSchema.safeParse(JSON.parse(text));
if (!result.success) return null;
const validCategories = ["dining", "outdoor", "entertainment", "shopping", "sports", "culture", "relaxation", "experience", "nature"]; return result.data as IdeaTags;
const validTimeSlots = ["morning", "afternoon", "evening", "flexible", "all_day"];
const validSearchTypes = ["brand", "place", "category"];
const validCostLevels = ["free", "budget", "moderate", "premium"];
const validIntensities = ["chill", "moderate", "active"];
if (
!validCategories.includes(parsed.category) ||
!validTimeSlots.includes(parsed.timeSlot) ||
!validSearchTypes.includes(parsed.searchType) ||
typeof parsed.estimatedMinutes !== "number" ||
!validCostLevels.includes(parsed.costLevel) ||
!validIntensities.includes(parsed.intensity) ||
typeof parsed.needsBooking !== "boolean" ||
typeof parsed.searchQuery !== "string"
) {
return null;
}
return {
category: parsed.category,
timeSlot: parsed.timeSlot,
estimatedMinutes: parsed.estimatedMinutes,
costLevel: parsed.costLevel,
intensity: parsed.intensity,
needsBooking: parsed.needsBooking,
searchQuery: parsed.searchQuery,
searchType: parsed.searchType,
};
} catch (e) { } catch (e) {
console.error("tagIdea failed:", e); console.error("tagIdea failed:", e);
return null; return null;
@@ -165,11 +145,11 @@ export async function suggestIdeas(existingIdeas: string[]): Promise<string[]> {
const text = response.choices[0]?.message?.content; const text = response.choices[0]?.message?.content;
if (!text) return []; if (!text) return [];
const parsed = JSON.parse(text); const result = SuggestIdeasSchema.safeParse(JSON.parse(text));
if (!Array.isArray(parsed.suggestions)) return []; if (!result.success) return [];
return parsed.suggestions return result.data.suggestions
.filter((s: unknown) => typeof s === "string" && s.length > 0) .filter((s) => s.length > 0)
.slice(0, 4); .slice(0, 4);
} catch (e) { } catch (e) {
console.error("suggestIdeas failed:", e); console.error("suggestIdeas failed:", e);
@@ -214,29 +194,17 @@ ${Object.entries(ctx.candidates)
const text = response.choices[0]?.message?.content; const text = response.choices[0]?.message?.content;
if (!text) return null; if (!text) return null;
const parsed = JSON.parse(text); const result = GenerateScheduleSchema.safeParse(JSON.parse(text));
if (!Array.isArray(parsed.items) || parsed.items.length === 0) return null; if (!result.success) return null;
return { return {
items: parsed.items.map((item: Record<string, unknown>) => ({ items: result.data.items.map((item) => ({
time: String(item.time ?? ""), ...item,
activity: String(item.activity ?? ""), ...(item.transitToNext != null && item.transitToNext > 0
poi: String(item.poi ?? ""), ? { transitToNext: Math.round(item.transitToNext) }
address: String(item.address ?? ""), : { transitToNext: undefined, transitDescription: undefined }),
lat: Number(item.lat) || 0, })) as PlanItem[],
lng: Number(item.lng) || 0, summary: result.data.summary,
duration: Number(item.duration) || 60,
reason: String(item.reason ?? ""),
...(item.transitToNext != null && Number(item.transitToNext) > 0
? {
transitToNext: Math.round(Number(item.transitToNext)),
...(item.transitDescription
? { transitDescription: String(item.transitDescription) }
: {}),
}
: {}),
})),
summary: String(parsed.summary ?? ""),
}; };
} catch (e) { } catch (e) {
console.error("generateSchedule failed:", e); console.error("generateSchedule failed:", e);
@@ -278,15 +246,11 @@ export async function refinePlan(
if (!text) return null; if (!text) return null;
const parsed = JSON.parse(text); const parsed = JSON.parse(text);
const result = parsed.days ?? parsed; const rawResult = parsed.days ?? parsed;
if (!Array.isArray(result) || result.length === 0) return null; const result = RefinePlanSchema.safeParse({ days: rawResult });
if (!result.every((d: unknown) => { if (!result.success) return null;
if (typeof d !== "object" || d === null) return false;
const day = d as Record<string, unknown>;
return typeof day.date === "string" && Array.isArray(day.items);
})) return null;
return result as import("@/types").WeekendPlanData[]; return result.data.days as unknown as import("@/types").WeekendPlanData[];
} catch (e) { } catch (e) {
console.error("refinePlan failed:", e); console.error("refinePlan failed:", e);
return null; return null;
@@ -329,18 +293,10 @@ export async function suggestAlternativeItems(
const text = response.choices[0]?.message?.content; const text = response.choices[0]?.message?.content;
if (!text) return null; if (!text) return null;
const parsed = JSON.parse(text); const result = SuggestAlternativesSchema.safeParse(JSON.parse(text));
if (!Array.isArray(parsed.alternatives)) return null; if (!result.success) return null;
return parsed.alternatives return result.data.alternatives.slice(0, 3) as Array<{ activity: string; searchQuery: string; reason: string }>;
.filter(
(a: unknown) =>
typeof a === "object" &&
a !== null &&
typeof (a as Record<string, unknown>).activity === "string" &&
typeof (a as Record<string, unknown>).searchQuery === "string",
)
.slice(0, 3) as Array<{ activity: string; searchQuery: string; reason: string }>;
} catch (e) { } catch (e) {
console.error("suggestAlternativeItems failed:", e); console.error("suggestAlternativeItems failed:", e);
return null; return null;
+1 -1
View File
@@ -98,7 +98,7 @@ describe("apiHandler", () => {
const res = await handler(req, mockCtx); const res = await handler(req, mockCtx);
const data = await res.json(); const data = await res.json();
expect(res.status).toBe(500); expect(res.status).toBe(500);
expect(data.error).toBe("操作失败"); expect(data.error).toBe("操作失败 [Error: unexpected]");
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });
}); });
+4 -710
View File
@@ -1,710 +1,4 @@
/** // Re-export from refactored modules for backward compatibility
* Shared plan generation logic for blindbox weekend plans. export type { PlanGenAvailableTime, PlanGenResult } from "./plan/index";
* Supports optional progress callback for streaming UX. export { runPlanGeneration } from "./plan/index";
* export { PLAN_PROGRESS_MESSAGES } from "./plan/legacyPlan";
* Primary path: tool-calling agent (runAgentPlanGeneration)
* Fallback path: legacy pipeline (runLegacyPlanGeneration)
*/
import { prisma } from "@/lib/prisma";
import { searchPois, getTransitDirection } from "@/lib/amap";
import { generateSchedule, runAgentLoop, type ScheduleContext, type AgentTool } from "@/lib/ai";
import { ApiError } from "@/lib/api";
export interface PlanGenAvailableTime {
date: string;
startHour: number;
endHour: number;
}
interface TaggedIdea {
id: string;
content: string;
category: string;
timeSlot: string;
estimatedMinutes: number;
searchQuery: string;
searchType: string;
costLevel: string | null;
intensity: string | null;
needsBooking: boolean | null;
}
// ---------------------------------------------------------------------------
// Slot-based idea selection (used by legacy path)
// ---------------------------------------------------------------------------
const SLOT_CATEGORY_MAP: Record<string, string[]> = {
morning: ["outdoor", "nature", "sports", "culture"],
lunch: ["dining"],
afternoon: ["entertainment", "shopping", "relaxation", "outdoor", "culture", "experience"],
dinner: ["dining"],
evening: ["entertainment", "relaxation", "experience"],
};
const SLOT_TIME_MAP: Record<string, string[]> = {
morning: ["morning", "flexible"],
lunch: ["flexible"],
afternoon: ["afternoon", "flexible"],
dinner: ["evening", "flexible"],
evening: ["evening", "flexible"],
};
function matchesSlot(idea: TaggedIdea, slot: string): boolean {
if (idea.timeSlot === "all_day") return true;
const validTimes = SLOT_TIME_MAP[slot];
return validTimes ? validTimes.includes(idea.timeSlot) : false;
}
function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): TaggedIdea[] {
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>();
function pickRandom(pool: TaggedIdea[]): TaggedIdea | null {
if (pool.length === 0) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
for (const slot of slots) {
const remaining = ideas.filter((i) => !usedIds.has(i.id));
const preferredCategories = SLOT_CATEGORY_MAP[slot] || [];
const p1 = remaining.filter(
(i) => matchesSlot(i, slot) && preferredCategories.includes(i.category),
);
let picked = pickRandom(p1);
if (!picked) {
for (const cat of preferredCategories) {
const pool = remaining.filter((i) => i.category === cat);
picked = pickRandom(pool);
if (picked) break;
}
}
if (!picked) {
const p3 = remaining.filter((i) => matchesSlot(i, slot));
picked = pickRandom(p3);
}
if (!picked) {
picked = pickRandom(remaining);
}
if (picked) {
selected.push(picked);
usedIds.add(picked.id);
}
}
return selected;
}
// ---------------------------------------------------------------------------
// Progress messages (kept for legacy path)
// ---------------------------------------------------------------------------
export const PLAN_PROGRESS_MESSAGES = {
analyzing: "正在分析你们的想法...",
searching: "正在搜索地点...",
planning: "正在规划路线...",
planningDay: (day: string) => `正在规划${day}...`,
almostDone: "快好了...",
} as const;
// ---------------------------------------------------------------------------
// Result type
// ---------------------------------------------------------------------------
export interface PlanGenResult {
id: string;
days: { date: string; items: unknown[]; summary: string }[];
createdAt: string;
}
// ---------------------------------------------------------------------------
// Agent path: tool-calling agent
// ---------------------------------------------------------------------------
const AGENT_SYSTEM_PROMPT = `你是一个周末行程规划 Agent。你有以下工具可以使用:
- list_ideas: 查看想法池中的所有活动
- search_poi: 在地图上搜索地点(支持品牌名、地名、品类搜索)
- get_travel_time: 查询两点间公共交通(地铁/公交)时间和距离
- finalize_plan: 提交最终行程方案
规划流程:
1. 先用 list_ideas 了解有哪些活动可选
2. 根据时间、多样性、强度平衡选出合适的活动组合
3. 为每个活动 search_poi 找到具体地点
4. 如果搜索结果不理想(0结果或不相关),尝试换关键词重搜
5. 用 get_travel_time 检查关键路段,如果某段超过 45 分钟考虑换更近的地点
6. 确认地点合理后 finalize_plan 提交
规划原则:
- 地理位置相近,最小化移动距离
- 严格遵守用餐时间窗口:午餐安排在 11:30-13:00,晚餐安排在 17:30-19:30,不得超出此范围
- 尊重时间偏好(morning 的活动放 9:00-12:00afternoon 的活动放 13:00-17:00evening 的活动放 19:00 以后)
- 活动间留 15-30 分钟交通时间
- 高低强度交替,避免连续高体力活动
- 费用均衡,不连续安排 premium 活动
- needsBooking 的活动在 reason 中提醒预约
每次调用工具前,用一句简短的话说明你的想法(用户能看到)。`;
interface FinalizePlanDay {
date: string;
items: {
time: string;
activity: string;
poi: string;
address: string;
lat: number;
lng: number;
duration: number;
reason: string;
}[];
summary: string;
}
function buildAgentTools(
taggedIdeas: TaggedIdea[],
lat: number,
lng: number,
city: string,
): AgentTool[] {
const listIdeasTool: AgentTool = {
name: "list_ideas",
description: "获取想法池中所有已标记的活动想法,包含品类、时间偏好、费用、强度等信息",
parameters: {
type: "object",
properties: {},
required: [],
},
execute: async () => {
const ideas = taggedIdeas.map((i) => ({
id: i.id,
content: i.content,
category: i.category,
timeSlot: i.timeSlot,
estimatedMinutes: i.estimatedMinutes,
costLevel: i.costLevel,
intensity: i.intensity,
needsBooking: i.needsBooking,
}));
return JSON.stringify(ideas);
},
progressBefore: () => "正在查看想法池...",
progressAfter: (_args, result) => {
const ideas = JSON.parse(result);
return `找到 ${ideas.length} 个想法`;
},
};
const searchPoiTool: AgentTool = {
name: "search_poi",
description: "在地图上搜索地点。search_type: brand=连锁品牌, place=唯一地点, category=模糊品类搜附近",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "搜索关键词(品牌名、地名或品类名)" },
search_type: {
type: "string",
enum: ["brand", "place", "category"],
description: "搜索策略",
},
},
required: ["query", "search_type"],
},
execute: async (args) => {
const query = String(args.query ?? "");
const searchType = String(args.search_type ?? "category");
try {
const pois = await searchPois(query, searchType, lat, lng);
return JSON.stringify(pois);
} catch (e) {
console.error("searchPoiTool failed:", e);
return JSON.stringify([]);
}
},
progressBefore: (args) => `正在搜索「${args.query}」...`,
progressAfter: (args, result) => {
const pois = JSON.parse(result);
return pois.length > 0
? `找到 ${pois.length} 个「${args.query}」相关地点`
: `未找到「${args.query}」的结果`;
},
};
const getTravelTimeTool: AgentTool = {
name: "get_travel_time",
description: "查询两点之间的公共交通(地铁/公交)预估时间和距离。用于验证活动之间的交通是否合理",
parameters: {
type: "object",
properties: {
origin_lat: { type: "number", description: "起点纬度" },
origin_lng: { type: "number", description: "起点经度" },
dest_lat: { type: "number", description: "终点纬度" },
dest_lng: { type: "number", description: "终点经度" },
},
required: ["origin_lat", "origin_lng", "dest_lat", "dest_lng"],
},
execute: async (args) => {
try {
const result = await getTransitDirection({
originLat: Number(args.origin_lat),
originLng: Number(args.origin_lng),
destLat: Number(args.dest_lat),
destLng: Number(args.dest_lng),
city,
});
if (!result) return JSON.stringify({ error: "未找到公交路线" });
return JSON.stringify(result);
} catch (e) {
console.error("getTravelTimeTool failed:", e);
return JSON.stringify({ error: "路线查询失败" });
}
},
progressBefore: () => "正在查询公共交通时间...",
progressAfter: (_args, result) => {
const r = JSON.parse(result);
if (r.error) return `路线查询失败`;
return `${r.description}${r.durationMin} 分钟(${r.distanceKm}km`;
},
};
const finalizePlanTool: AgentTool = {
name: "finalize_plan",
description: "提交最终行程方案。days 数组中每个元素包含 date、items(活动列表)和 summary",
parameters: {
type: "object",
properties: {
days: {
type: "array",
items: {
type: "object",
properties: {
date: { type: "string" },
items: {
type: "array",
items: {
type: "object",
properties: {
time: { type: "string", description: "开始时间,如 10:00" },
activity: { type: "string", description: "活动描述" },
poi: { type: "string", description: "具体地点名称" },
address: { type: "string", description: "详细地址" },
lat: { type: "number" },
lng: { type: "number" },
duration: { type: "number", description: "时长(分钟)" },
reason: { type: "string", description: "选择理由" },
},
required: ["time", "activity", "poi", "address", "lat", "lng", "duration", "reason"],
},
},
summary: { type: "string", description: "当天行程亮点一句话总结" },
},
required: ["date", "items", "summary"],
},
},
},
required: ["days"],
},
execute: async () => {
return JSON.stringify({ success: true });
},
progressBefore: () => "正在整理最终行程...",
progressAfter: () => "行程规划完成!",
};
return [listIdeasTool, searchPoiTool, getTravelTimeTool, finalizePlanTool];
}
async function runAgentPlanGeneration(
room: { lat: number; lng: number; city: string },
taggedIdeas: TaggedIdea[],
availableTime: PlanGenAvailableTime,
onProgress?: (message: string) => void,
): Promise<{ days: FinalizePlanDay[] }> {
const at = availableTime;
const tools = buildAgentTools(taggedIdeas, room.lat, room.lng, room.city ?? "");
const userPrompt = `帮我规划行程。
可用时间:${at.date}${at.startHour}:00 - ${at.endHour}:00
出发地(家):纬度 ${room.lat},经度 ${room.lng}
注意:第一个活动需要从出发地出发,最后一个活动结束后需要返回出发地,请用 get_travel_time 评估出发/回程时间并计入全天时间预算(确保最后一个活动结束时间 + 回程时间 ≤ ${at.endHour}:00)。
请开始规划。`;
const result = await runAgentLoop({
systemPrompt: AGENT_SYSTEM_PROMPT,
userPrompt,
tools,
onProgress,
maxTurns: 15,
});
if (!result) {
throw new Error("Agent 未能在限定轮次内完成规划");
}
const days = result.finalArgs.days as FinalizePlanDay[] | undefined;
if (!Array.isArray(days) || days.length === 0) {
throw new Error("Agent 返回的行程数据无效");
}
// Normalize the items
const normalizedDays = days.map((day) => ({
date: String(day.date ?? ""),
items: Array.isArray(day.items)
? day.items.map((item) => ({
time: String(item.time ?? ""),
activity: String(item.activity ?? ""),
poi: String(item.poi ?? ""),
address: String(item.address ?? ""),
lat: Number(item.lat) || 0,
lng: Number(item.lng) || 0,
duration: Number(item.duration) || 60,
reason: String(item.reason ?? ""),
}))
: [],
summary: String(day.summary ?? ""),
}));
const validDays = normalizedDays.filter((d) => d.items.length > 0);
if (validDays.length === 0) {
throw new Error("Agent 返回的行程中没有有效活动");
}
return { days: validDays };
}
// ---------------------------------------------------------------------------
// Legacy path: deterministic pipeline
// ---------------------------------------------------------------------------
async function runLegacyPlanGeneration(
room: { lat: number; lng: number },
taggedIdeas: TaggedIdea[],
availableTime: PlanGenAvailableTime,
onProgress?: (message: string) => void,
): Promise<{ days: { date: string; items: unknown[]; summary: string }[] }> {
const at = availableTime;
const dayConfigs: PlanGenAvailableTime[] =
at.date === "整个周末"
? [
{ date: "周六", startHour: at.startHour, endHour: at.endHour },
{ date: "周日", startHour: at.startHour, endHour: at.endHour },
]
: [at];
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);
}
const actualDayConfigs = dayConfigs.slice(0, dayIdeas.length);
const allSelected = dayIdeas.flat();
if (allSelected.length === 0) {
throw new ApiError("无法从想法池中选出合适的活动", 400);
}
const uniqueByQuery = new Map<string, TaggedIdea>();
for (const idea of allSelected) {
if (!uniqueByQuery.has(idea.searchQuery)) uniqueByQuery.set(idea.searchQuery, idea);
}
onProgress?.(PLAN_PROGRESS_MESSAGES.searching);
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 (e) {
console.error(`searchPois failed for "${idea.searchQuery}":`, e);
return { query: idea.searchQuery, pois: [] };
}
}),
);
const toCandidates = (pois: { name: string; address: string; lat: number; lng: number; rating?: number | null }[]) =>
pois.map((p) => ({ ...p, rating: p.rating ?? undefined }));
const candidates: ScheduleContext["candidates"] = {};
for (const result of searchResults) {
candidates[result.query] = toCandidates(result.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 (e) {
console.error(`searchPois (category) failed for "${idea.searchQuery}":`, e);
return { query: idea.searchQuery, pois: [] };
}
}),
);
for (const result of catResults) {
candidates[result.query] = toCandidates(result.pois);
}
}
onProgress?.(PLAN_PROGRESS_MESSAGES.planning);
const schedules = await Promise.all(
actualDayConfigs.map((dayConfig, idx) => {
onProgress?.(PLAN_PROGRESS_MESSAGES.planningDay(dayConfig.date));
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,
costLevel: i.costLevel ?? undefined,
intensity: i.intensity ?? undefined,
needsBooking: i.needsBooking ?? undefined,
})),
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 is NonNullable<typeof d> => d !== null);
if (days.length === 0) {
throw new ApiError("AI 规划失败,请稍后重试", 500);
}
return { days };
}
// ---------------------------------------------------------------------------
// Public entry point (signature unchanged)
// ---------------------------------------------------------------------------
// Post-processing: compute real transit info from Amap and store in plan
// ---------------------------------------------------------------------------
async function queryTransit(
oLng: number,
oLat: number,
dLng: number,
dLat: number,
city: string,
): Promise<{ durationMin: number; description: string } | null> {
try {
const result = await getTransitDirection({
originLat: oLat,
originLng: oLng,
destLat: dLat,
destLng: dLng,
city,
});
if (!result) return null;
return { durationMin: result.durationMin, description: result.description };
} catch (e) {
console.error("queryTransit failed:", e);
return null;
}
}
type EnrichDay = {
date: string;
items: Record<string, unknown>[];
summary: string;
transitFromStart?: number;
transitFromStartDescription?: string;
transitToEnd?: number;
transitToEndDescription?: string;
};
async function enrichTransitInfo(
days: EnrichDay[],
city: string,
homeLat: number,
homeLng: number,
): Promise<void> {
const cityParam = city || "上海";
for (const day of days) {
const items = day.items;
// From home to first activity
if (items.length > 0 && homeLat && homeLng) {
const dLat = Number(items[0].lat);
const dLng = Number(items[0].lng);
if (dLat && dLng) {
const result = await queryTransit(homeLng, homeLat, dLng, dLat, cityParam);
if (result) {
day.transitFromStart = result.durationMin;
day.transitFromStartDescription = result.description;
}
}
}
// Between consecutive activities
for (let i = 0; i < items.length - 1; i++) {
const oLat = Number(items[i].lat);
const oLng = Number(items[i].lng);
const dLat = Number(items[i + 1].lat);
const dLng = Number(items[i + 1].lng);
if (!oLat || !oLng || !dLat || !dLng) continue;
const result = await queryTransit(oLng, oLat, dLng, dLat, cityParam);
if (result) {
items[i].transitToNext = result.durationMin;
items[i].transitDescription = result.description;
}
}
// From last activity back home
if (items.length > 0 && homeLat && homeLng) {
const last = items[items.length - 1];
const oLat = Number(last.lat);
const oLng = Number(last.lng);
if (oLat && oLng) {
const result = await queryTransit(oLng, oLat, homeLng, homeLat, cityParam);
if (result) {
day.transitToEnd = result.durationMin;
day.transitToEndDescription = result.description;
}
}
}
}
}
// ---------------------------------------------------------------------------
export async function runPlanGeneration(
roomId: string,
userId: string,
availableTime: PlanGenAvailableTime,
onProgress?: (message: string) => void,
): Promise<PlanGenResult> {
// 1. Fetch room & ideas (shared by both paths)
const room = await prisma.blindBoxRoom.findUnique({ where: { id: roomId } });
if (!room) throw new ApiError("房间不存在", 404);
if (!room.lat || !room.lng) {
throw new ApiError("请先设置房间位置", 400);
}
onProgress?.(PLAN_PROGRESS_MESSAGES.analyzing);
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,
costLevel: true,
intensity: true,
needsBooking: 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);
}
// 2. Try agent path, fallback to legacy
let days: { date: string; items: unknown[]; summary: string }[];
try {
const agentResult = await runAgentPlanGeneration(
{ lat: room.lat, lng: room.lng, city: room.city ?? "" },
taggedIdeas,
availableTime,
onProgress,
);
days = agentResult.days;
} catch (e) {
console.error("runAgentPlanGeneration failed, falling back to legacy:", e);
onProgress?.("使用备用方案规划...");
const legacyResult = await runLegacyPlanGeneration(
{ lat: room.lat, lng: room.lng },
taggedIdeas,
availableTime,
onProgress,
);
days = legacyResult.days;
}
// 3. Compute real transit info server-side (best-effort, errors are swallowed internally)
onProgress?.("正在查询交通信息...");
await enrichTransitInfo(
days as EnrichDay[],
room.city ?? "上海",
room.lat,
room.lng,
);
// 4. Save to DB (shared)
const plan = await prisma.weekendPlan.create({
data: {
roomId,
userId,
planData: JSON.stringify({ days }),
},
});
return {
id: plan.id,
days,
createdAt: plan.createdAt.toISOString(),
};
}
+2 -2
View File
@@ -8,7 +8,7 @@ import {
TEST_ROOM_DATA, TEST_ROOM_DATA,
} from "@/__tests__/helpers/fixtures"; } from "@/__tests__/helpers/fixtures";
vi.mock("@/lib/store", () => ({ vi.mock("@/lib/roomRepository", () => ({
getRoomData: vi.fn(), getRoomData: vi.fn(),
})); }));
@@ -21,7 +21,7 @@ vi.mock("@/lib/prisma", () => ({
})); }));
import { buildRoomStatus } from "@/lib/buildRoomStatus"; import { buildRoomStatus } from "@/lib/buildRoomStatus";
import { getRoomData } from "@/lib/store"; import { getRoomData } from "@/lib/roomRepository";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
const mockGetRoomData = vi.mocked(getRoomData); const mockGetRoomData = vi.mocked(getRoomData);
+1 -1
View File
@@ -1,4 +1,4 @@
import { getRoomData } from "./store"; import { getRoomData } from "./roomRepository";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import type { RoomStatus, MatchType, UserProfile } from "@/types"; import type { RoomStatus, MatchType, UserProfile } from "@/types";
+256
View File
@@ -0,0 +1,256 @@
// Agent-based plan generation (tool-calling loop)
import { searchPois, getTransitDirection } from "@/lib/amap";
import { runAgentLoop, type AgentTool } from "@/lib/ai";
import type { TaggedIdea } from "./ideaSelection";
import type { PlanGenAvailableTime } from "./index";
export const AGENT_SYSTEM_PROMPT = `你是一个周末行程规划 Agent。你有以下工具可以使用:
- list_ideas: 查看想法池中的所有活动
- search_poi: 在地图上搜索地点(支持品牌名、地名、品类搜索)
- get_travel_time: 查询两点间公共交通(地铁/公交)时间和距离
- finalize_plan: 提交最终行程方案
规划流程:
1. 先用 list_ideas 了解有哪些活动可选
2. 根据时间、多样性、强度平衡选出合适的活动组合
3. 为每个活动 search_poi 找到具体地点
4. 如果搜索结果不理想(0结果或不相关),尝试换关键词重搜
5. 用 get_travel_time 检查关键路段,如果某段超过 45 分钟考虑换更近的地点
6. 确认地点合理后 finalize_plan 提交
规划原则:
- 地理位置相近,最小化移动距离
- 严格遵守用餐时间窗口:午餐安排在 11:30-13:00,晚餐安排在 17:30-19:30,不得超出此范围
- 尊重时间偏好(morning 的活动放 9:00-12:00afternoon 的活动放 13:00-17:00evening 的活动放 19:00 以后)
- 活动间留 15-30 分钟交通时间
- 高低强度交替,避免连续高体力活动
- 费用均衡,不连续安排 premium 活动
- needsBooking 的活动在 reason 中提醒预约
每次调用工具前,用一句简短的话说明你的想法(用户能看到)。`;
export interface FinalizePlanDay {
date: string;
items: {
time: string;
activity: string;
poi: string;
address: string;
lat: number;
lng: number;
duration: number;
reason: string;
}[];
summary: string;
}
export function buildAgentTools(
taggedIdeas: TaggedIdea[],
lat: number,
lng: number,
city: string,
): AgentTool[] {
const listIdeasTool: AgentTool = {
name: "list_ideas",
description: "获取想法池中所有已标记的活动想法,包含品类、时间偏好、费用、强度等信息",
parameters: {
type: "object",
properties: {},
required: [],
},
execute: async () => {
const ideas = taggedIdeas.map((i) => ({
id: i.id,
content: i.content,
category: i.category,
timeSlot: i.timeSlot,
estimatedMinutes: i.estimatedMinutes,
costLevel: i.costLevel,
intensity: i.intensity,
needsBooking: i.needsBooking,
}));
return JSON.stringify(ideas);
},
progressBefore: () => "正在查看想法池...",
progressAfter: (_args, result) => {
const ideas = JSON.parse(result);
return `找到 ${ideas.length} 个想法`;
},
};
const searchPoiTool: AgentTool = {
name: "search_poi",
description: "在地图上搜索地点。search_type: brand=连锁品牌, place=唯一地点, category=模糊品类搜附近",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "搜索关键词(品牌名、地名或品类名)" },
search_type: {
type: "string",
enum: ["brand", "place", "category"],
description: "搜索策略",
},
},
required: ["query", "search_type"],
},
execute: async (args) => {
const query = String(args.query ?? "");
const searchType = String(args.search_type ?? "category");
try {
const pois = await searchPois(query, searchType, lat, lng);
return JSON.stringify(pois);
} catch (e) {
console.error("searchPoiTool failed:", e);
return JSON.stringify([]);
}
},
progressBefore: (args) => `正在搜索「${args.query}」...`,
progressAfter: (args, result) => {
const pois = JSON.parse(result);
return pois.length > 0
? `找到 ${pois.length} 个「${args.query}」相关地点`
: `未找到「${args.query}」的结果`;
},
};
const getTravelTimeTool: AgentTool = {
name: "get_travel_time",
description: "查询两点之间的公共交通(地铁/公交)预估时间和距离。用于验证活动之间的交通是否合理",
parameters: {
type: "object",
properties: {
origin_lat: { type: "number", description: "起点纬度" },
origin_lng: { type: "number", description: "起点经度" },
dest_lat: { type: "number", description: "终点纬度" },
dest_lng: { type: "number", description: "终点经度" },
},
required: ["origin_lat", "origin_lng", "dest_lat", "dest_lng"],
},
execute: async (args) => {
try {
const result = await getTransitDirection({
originLat: Number(args.origin_lat),
originLng: Number(args.origin_lng),
destLat: Number(args.dest_lat),
destLng: Number(args.dest_lng),
city,
});
if (!result) return JSON.stringify({ error: "未找到公交路线" });
return JSON.stringify(result);
} catch (e) {
console.error("getTravelTimeTool failed:", e);
return JSON.stringify({ error: "路线查询失败" });
}
},
progressBefore: () => "正在查询公共交通时间...",
progressAfter: (_args, result) => {
const r = JSON.parse(result);
if (r.error) return `路线查询失败`;
return `${r.description}${r.durationMin} 分钟(${r.distanceKm}km`;
},
};
const finalizePlanTool: AgentTool = {
name: "finalize_plan",
description: "提交最终行程方案。days 数组中每个元素包含 date、items(活动列表)和 summary",
parameters: {
type: "object",
properties: {
days: {
type: "array",
items: {
type: "object",
properties: {
date: { type: "string" },
items: {
type: "array",
items: {
type: "object",
properties: {
time: { type: "string", description: "开始时间,如 10:00" },
activity: { type: "string", description: "活动描述" },
poi: { type: "string", description: "具体地点名称" },
address: { type: "string", description: "详细地址" },
lat: { type: "number" },
lng: { type: "number" },
duration: { type: "number", description: "时长(分钟)" },
reason: { type: "string", description: "选择理由" },
},
required: ["time", "activity", "poi", "address", "lat", "lng", "duration", "reason"],
},
},
summary: { type: "string", description: "当天行程亮点一句话总结" },
},
required: ["date", "items", "summary"],
},
},
},
required: ["days"],
},
execute: async () => {
return JSON.stringify({ success: true });
},
progressBefore: () => "正在整理最终行程...",
progressAfter: () => "行程规划完成!",
};
return [listIdeasTool, searchPoiTool, getTravelTimeTool, finalizePlanTool];
}
export async function runAgentPlanGeneration(
room: { lat: number; lng: number; city: string },
taggedIdeas: TaggedIdea[],
availableTime: PlanGenAvailableTime,
onProgress?: (message: string) => void,
): Promise<{ days: FinalizePlanDay[] }> {
const at = availableTime;
const tools = buildAgentTools(taggedIdeas, room.lat, room.lng, room.city ?? "");
const userPrompt = `帮我规划行程。
可用时间:${at.date}${at.startHour}:00 - ${at.endHour}:00
出发地(家):纬度 ${room.lat},经度 ${room.lng}
注意:第一个活动需要从出发地出发,最后一个活动结束后需要返回出发地,请用 get_travel_time 评估出发/回程时间并计入全天时间预算(确保最后一个活动结束时间 + 回程时间 ≤ ${at.endHour}:00)。
请开始规划。`;
const result = await runAgentLoop({
systemPrompt: AGENT_SYSTEM_PROMPT,
userPrompt,
tools,
onProgress,
maxTurns: 15,
});
if (!result) {
throw new Error("Agent 未能在限定轮次内完成规划");
}
const days = result.finalArgs.days as FinalizePlanDay[] | undefined;
if (!Array.isArray(days) || days.length === 0) {
throw new Error("Agent 返回的行程数据无效");
}
const normalizedDays = days.map((day) => ({
date: String(day.date ?? ""),
items: Array.isArray(day.items)
? day.items.map((item) => ({
time: String(item.time ?? ""),
activity: String(item.activity ?? ""),
poi: String(item.poi ?? ""),
address: String(item.address ?? ""),
lat: Number(item.lat) || 0,
lng: Number(item.lng) || 0,
duration: Number(item.duration) || 60,
reason: String(item.reason ?? ""),
}))
: [],
summary: String(day.summary ?? ""),
}));
const validDays = normalizedDays.filter((d) => d.items.length > 0);
if (validDays.length === 0) {
throw new Error("Agent 返回的行程中没有有效活动");
}
return { days: validDays };
}
+91
View File
@@ -0,0 +1,91 @@
// Slot-based idea selection used by the legacy plan path
export interface TaggedIdea {
id: string;
content: string;
category: string;
timeSlot: string;
estimatedMinutes: number;
searchQuery: string;
searchType: string;
costLevel: string | null;
intensity: string | null;
needsBooking: boolean | null;
}
export const SLOT_CATEGORY_MAP: Record<string, string[]> = {
morning: ["outdoor", "nature", "sports", "culture"],
lunch: ["dining"],
afternoon: ["entertainment", "shopping", "relaxation", "outdoor", "culture", "experience"],
dinner: ["dining"],
evening: ["entertainment", "relaxation", "experience"],
};
export const SLOT_TIME_MAP: Record<string, string[]> = {
morning: ["morning", "flexible"],
lunch: ["flexible"],
afternoon: ["afternoon", "flexible"],
dinner: ["evening", "flexible"],
evening: ["evening", "flexible"],
};
export function matchesSlot(idea: TaggedIdea, slot: string): boolean {
if (idea.timeSlot === "all_day") return true;
const validTimes = SLOT_TIME_MAP[slot];
return validTimes ? validTimes.includes(idea.timeSlot) : false;
}
export function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): TaggedIdea[] {
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>();
function pickRandom(pool: TaggedIdea[]): TaggedIdea | null {
if (pool.length === 0) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
for (const slot of slots) {
const remaining = ideas.filter((i) => !usedIds.has(i.id));
const preferredCategories = SLOT_CATEGORY_MAP[slot] || [];
const p1 = remaining.filter(
(i) => matchesSlot(i, slot) && preferredCategories.includes(i.category),
);
let picked = pickRandom(p1);
if (!picked) {
for (const cat of preferredCategories) {
const pool = remaining.filter((i) => i.category === cat);
picked = pickRandom(pool);
if (picked) break;
}
}
if (!picked) {
const p3 = remaining.filter((i) => matchesSlot(i, slot));
picked = pickRandom(p3);
}
if (!picked) {
picked = pickRandom(remaining);
}
if (picked) {
selected.push(picked);
usedIds.add(picked.id);
}
}
return selected;
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Plan generation entry point.
* Tries the agent path first, falls back to legacy pipeline.
*/
import { prisma } from "@/lib/prisma";
import { ApiError } from "@/lib/api";
import { runAgentPlanGeneration } from "./agentPlan";
import { runLegacyPlanGeneration, PLAN_PROGRESS_MESSAGES } from "./legacyPlan";
import { enrichTransitInfo, type EnrichDay } from "./transitEnrichment";
import type { TaggedIdea } from "./ideaSelection";
export interface PlanGenAvailableTime {
date: string;
startHour: number;
endHour: number;
}
export interface PlanGenResult {
id: string;
days: { date: string; items: unknown[]; summary: string }[];
createdAt: string;
}
export async function runPlanGeneration(
roomId: string,
userId: string,
availableTime: PlanGenAvailableTime,
onProgress?: (message: string) => void,
): Promise<PlanGenResult> {
// 1. Fetch room & ideas
const room = await prisma.blindBoxRoom.findUnique({ where: { id: roomId } });
if (!room) throw new ApiError("房间不存在", 404);
if (!room.lat || !room.lng) {
throw new ApiError("请先设置房间位置", 400);
}
onProgress?.(PLAN_PROGRESS_MESSAGES.analyzing);
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,
costLevel: true,
intensity: true,
needsBooking: 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);
}
// 2. Try agent path, fallback to legacy
let days: { date: string; items: unknown[]; summary: string }[];
try {
const agentResult = await runAgentPlanGeneration(
{ lat: room.lat, lng: room.lng, city: room.city ?? "" },
taggedIdeas,
availableTime,
onProgress,
);
days = agentResult.days;
} catch (e) {
console.error("runAgentPlanGeneration failed, falling back to legacy:", e);
onProgress?.("使用备用方案规划...");
const legacyResult = await runLegacyPlanGeneration(
{ lat: room.lat, lng: room.lng },
taggedIdeas,
availableTime,
onProgress,
);
days = legacyResult.days;
}
// 3. Enrich transit info (best-effort)
onProgress?.("正在查询交通信息...");
await enrichTransitInfo(
days as EnrichDay[],
room.city ?? "上海",
room.lat,
room.lng,
);
// 4. Save to DB
const plan = await prisma.weekendPlan.create({
data: {
roomId,
userId,
planData: JSON.stringify({ days }),
},
});
return {
id: plan.id,
days,
createdAt: plan.createdAt.toISOString(),
};
}
+145
View File
@@ -0,0 +1,145 @@
// Legacy deterministic pipeline for plan generation
import { searchPois } from "@/lib/amap";
import { generateSchedule, type ScheduleContext } from "@/lib/ai";
import { ApiError } from "@/lib/api";
import { selectIdeasForSlots, type TaggedIdea } from "./ideaSelection";
import type { PlanGenAvailableTime } from "./index";
export const PLAN_PROGRESS_MESSAGES = {
analyzing: "正在分析你们的想法...",
searching: "正在搜索地点...",
planning: "正在规划路线...",
planningDay: (day: string) => `正在规划${day}...`,
almostDone: "快好了...",
} as const;
export async function runLegacyPlanGeneration(
room: { lat: number; lng: number },
taggedIdeas: TaggedIdea[],
availableTime: PlanGenAvailableTime,
onProgress?: (message: string) => void,
): Promise<{ days: { date: string; items: unknown[]; summary: string }[] }> {
const at = availableTime;
const dayConfigs: PlanGenAvailableTime[] =
at.date === "整个周末"
? [
{ date: "周六", startHour: at.startHour, endHour: at.endHour },
{ date: "周日", startHour: at.startHour, endHour: at.endHour },
]
: [at];
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);
}
const actualDayConfigs = dayConfigs.slice(0, dayIdeas.length);
const allSelected = dayIdeas.flat();
if (allSelected.length === 0) {
throw new ApiError("无法从想法池中选出合适的活动", 400);
}
const uniqueByQuery = new Map<string, TaggedIdea>();
for (const idea of allSelected) {
if (!uniqueByQuery.has(idea.searchQuery)) uniqueByQuery.set(idea.searchQuery, idea);
}
onProgress?.(PLAN_PROGRESS_MESSAGES.searching);
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 (e) {
console.error(`searchPois failed for "${idea.searchQuery}":`, e);
return { query: idea.searchQuery, pois: [] };
}
}),
);
const toCandidates = (pois: { name: string; address: string; lat: number; lng: number; rating?: number | null }[]) =>
pois.map((p) => ({ ...p, rating: p.rating ?? undefined }));
const candidates: ScheduleContext["candidates"] = {};
for (const result of searchResults) {
candidates[result.query] = toCandidates(result.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 (e) {
console.error(`searchPois (category) failed for "${idea.searchQuery}":`, e);
return { query: idea.searchQuery, pois: [] };
}
}),
);
for (const result of catResults) {
candidates[result.query] = toCandidates(result.pois);
}
}
onProgress?.(PLAN_PROGRESS_MESSAGES.planning);
const schedules = await Promise.all(
actualDayConfigs.map((dayConfig, idx) => {
onProgress?.(PLAN_PROGRESS_MESSAGES.planningDay(dayConfig.date));
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,
costLevel: i.costLevel ?? undefined,
intensity: i.intensity ?? undefined,
needsBooking: i.needsBooking ?? undefined,
})),
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 is NonNullable<typeof d> => d !== null);
if (days.length === 0) {
throw new ApiError("AI 规划失败,请稍后重试", 500);
}
return { days };
}
+90
View File
@@ -0,0 +1,90 @@
// Post-processing: compute real transit info from Amap and store in plan
import { getTransitDirection } from "@/lib/amap";
async function queryTransit(
oLng: number,
oLat: number,
dLng: number,
dLat: number,
city: string,
): Promise<{ durationMin: number; description: string } | null> {
try {
const result = await getTransitDirection({
originLat: oLat,
originLng: oLng,
destLat: dLat,
destLng: dLng,
city,
});
if (!result) return null;
return { durationMin: result.durationMin, description: result.description };
} catch (e) {
console.error("queryTransit failed:", e);
return null;
}
}
export type EnrichDay = {
date: string;
items: Record<string, unknown>[];
summary: string;
transitFromStart?: number;
transitFromStartDescription?: string;
transitToEnd?: number;
transitToEndDescription?: string;
};
export async function enrichTransitInfo(
days: EnrichDay[],
city: string,
homeLat: number,
homeLng: number,
): Promise<void> {
const cityParam = city || "上海";
for (const day of days) {
const items = day.items;
// From home to first activity
if (items.length > 0 && homeLat && homeLng) {
const dLat = Number(items[0].lat);
const dLng = Number(items[0].lng);
if (dLat && dLng) {
const result = await queryTransit(homeLng, homeLat, dLng, dLat, cityParam);
if (result) {
day.transitFromStart = result.durationMin;
day.transitFromStartDescription = result.description;
}
}
}
// Between consecutive activities
for (let i = 0; i < items.length - 1; i++) {
const oLat = Number(items[i].lat);
const oLng = Number(items[i].lng);
const dLat = Number(items[i + 1].lat);
const dLng = Number(items[i + 1].lng);
if (!oLat || !oLng || !dLat || !dLng) continue;
const result = await queryTransit(oLng, oLat, dLng, dLat, cityParam);
if (result) {
items[i].transitToNext = result.durationMin;
items[i].transitDescription = result.description;
}
}
// From last activity back home
if (items.length > 0 && homeLat && homeLng) {
const last = items[items.length - 1];
const oLat = Number(last.lat);
const oLng = Number(last.lng);
if (oLat && oLng) {
const result = await queryTransit(oLng, oLat, homeLng, homeLat, cityParam);
if (result) {
day.transitToEnd = result.durationMin;
day.transitToEndDescription = result.description;
}
}
}
}
}
+95
View File
@@ -0,0 +1,95 @@
import { prisma } from "@/lib/prisma";
import { ApiError } from "@/lib/api";
/**
* Map "周六"/"周日" to the next occurrence of that weekday from a reference date.
* Returns a Date at 00:00 of that day.
*/
export 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;
}
export 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 async function handlePlanUpdate(
planId: string,
userId: string,
action: string,
days: unknown[],
): Promise<{ ok: true; endTime?: Date | null }> {
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 { 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 { 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 { ok: true };
}
throw new ApiError("无效的操作", 400);
}
+85
View File
@@ -0,0 +1,85 @@
import { prisma } from "@/lib/prisma";
import { ApiError } from "@/lib/api";
export async function getLatestPlan(roomId: string, userId: string) {
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 { plan: null };
const parsed = JSON.parse(plan.planData);
return {
plan: { id: plan.id, days: parsed.days, endTime: plan.endTime, createdAt: plan.createdAt },
};
}
export async function getPendingPlans(userId: string) {
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 { pending: result };
}
export async function getHistoryPlans(userId: string) {
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 { history: result };
}
+212
View File
@@ -0,0 +1,212 @@
import { prisma } from "./prisma";
import { Prisma } from "@prisma/client";
import { Restaurant, SceneType } from "@/types";
const ROOM_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
export interface RoomData {
users: string[];
restaurants: Restaurant[];
likes: Record<string, string[]>;
swipeCounts: Record<string, number>;
match: string | null;
creatorId: string;
locked: boolean;
kickedUsers: string[];
scene: SceneType;
}
const ROOM_ID_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
function generateRoomId(): string {
let id = "";
for (let i = 0; i < 6; i++) {
id += ROOM_ID_CHARS[Math.floor(Math.random() * ROOM_ID_CHARS.length)];
}
return id;
}
let lastCleanup = 0;
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
async function cleanupExpiredRooms() {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
lastCleanup = now;
try {
const { count } = await prisma.room.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
if (count > 0) {
console.log(`Cleaned up ${count} expired room(s)`);
}
} catch (e) {
console.error("Room cleanup failed:", e);
}
}
export async function createRoom(restaurants: Restaurant[], creatorId: string, scene: SceneType = "eat"): Promise<string> {
await cleanupExpiredRooms();
const expiresAt = new Date(Date.now() + ROOM_TTL_MS);
for (let attempts = 0; attempts < 20; attempts++) {
const roomId = generateRoomId();
try {
await prisma.$transaction(async (tx) => {
await tx.room.create({
data: { id: roomId, creatorId, scene, expiresAt },
});
if (restaurants.length > 0) {
await tx.roomRestaurant.createMany({
data: restaurants.map((r) => ({
roomId,
restaurantData: JSON.stringify(r),
})),
});
}
});
return roomId;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
continue;
}
throw e;
}
}
throw new Error("无法生成唯一房间号,请稍后重试");
}
export async function getRoomData(roomId: string): Promise<RoomData | null> {
const room = await prisma.room.findUnique({
where: { id: roomId },
include: {
members: true,
restaurants: true,
likes: true,
swipes: true,
},
});
if (!room) return null;
if (room.expiresAt < new Date()) {
await prisma.room.delete({ where: { id: roomId } }).catch((e) => {
console.error(`Failed to delete expired room ${roomId}:`, e);
});
return null;
}
const users = room.members.filter((m) => !m.kicked).map((m) => m.userId);
const kickedUsers = room.members.filter((m) => m.kicked).map((m) => m.userId);
const restaurants: Restaurant[] = room.restaurants.map((r) => JSON.parse(r.restaurantData));
const likes: Record<string, string[]> = {};
for (const like of room.likes) {
if (!likes[like.restaurantId]) likes[like.restaurantId] = [];
likes[like.restaurantId].push(like.userId);
}
const swipeCounts: Record<string, number> = {};
for (const swipe of room.swipes) {
swipeCounts[swipe.userId] = swipe.count;
}
return {
users,
restaurants,
likes,
swipeCounts,
match: room.match,
creatorId: room.creatorId,
locked: room.locked,
kickedUsers,
scene: room.scene as SceneType,
};
}
/**
* Per-room mutex to serialize concurrent read-modify-write operations.
*/
const roomLocks = new Map<string, Promise<unknown>>();
function withRoomLock<T>(roomId: string, fn: () => Promise<T>): Promise<T> {
const prev = roomLocks.get(roomId) ?? Promise.resolve();
const next = prev.then(fn, fn);
roomLocks.set(roomId, next);
next.finally(() => {
if (roomLocks.get(roomId) === next) {
roomLocks.delete(roomId);
}
});
return next;
}
/**
* Atomic read-modify-write with per-room serialization.
* Translates RoomData mutations into relational table operations.
*/
export async function atomicUpdateRoom(
roomId: string,
updater: (data: RoomData) => RoomData,
): Promise<RoomData | null> {
return withRoomLock(roomId, async () => {
const current = await getRoomData(roomId);
if (!current) return null;
const updated = updater(current);
await prisma.$transaction(async (tx) => {
// Update room-level fields
await tx.room.update({
where: { id: roomId },
data: {
scene: updated.scene,
locked: updated.locked,
match: updated.match,
},
});
// Sync all members (users + kicked)
const allMemberIds = new Set([...updated.users, ...updated.kickedUsers]);
const kickedSet = new Set(updated.kickedUsers);
for (const userId of allMemberIds) {
await tx.roomMember.upsert({
where: { roomId_userId: { roomId, userId } },
update: { kicked: kickedSet.has(userId) },
create: { roomId, userId, kicked: kickedSet.has(userId) },
});
}
// Sync likes: rebuild from updated data
if (JSON.stringify(current.likes) !== JSON.stringify(updated.likes)) {
await tx.roomLike.deleteMany({ where: { roomId } });
const likeRows: { roomId: string; userId: string; restaurantId: string }[] = [];
for (const [restaurantId, userIds] of Object.entries(updated.likes)) {
for (const userId of userIds) {
likeRows.push({ roomId, userId, restaurantId });
}
}
if (likeRows.length > 0) {
await tx.roomLike.createMany({ data: likeRows });
}
}
// Sync swipes
if (JSON.stringify(current.swipeCounts) !== JSON.stringify(updated.swipeCounts)) {
for (const [userId, count] of Object.entries(updated.swipeCounts)) {
await tx.roomSwipe.upsert({
where: { roomId_userId: { roomId, userId } },
update: { count },
create: { roomId, userId, count },
});
}
}
});
return updated;
});
}
+58
View File
@@ -0,0 +1,58 @@
import { z } from "zod";
export const IdeaTagsSchema = z.object({
category: z.enum([
"dining", "outdoor", "entertainment", "shopping", "sports",
"culture", "relaxation", "experience", "nature",
]),
timeSlot: z.enum(["morning", "afternoon", "evening", "flexible", "all_day"]),
estimatedMinutes: z.number().int().positive(),
costLevel: z.enum(["free", "budget", "moderate", "premium"]),
intensity: z.enum(["chill", "moderate", "active"]),
needsBooking: z.boolean(),
searchQuery: z.string().min(1),
searchType: z.enum(["brand", "place", "category"]),
});
export type IdeaTagsSchema = z.infer<typeof IdeaTagsSchema>;
export const SuggestIdeasSchema = z.object({
suggestions: z.array(z.string()),
});
export const PlanItemSchema = z.object({
time: z.string(),
activity: z.string(),
poi: z.string(),
address: z.string(),
lat: z.number(),
lng: z.number(),
duration: z.number(),
reason: z.string(),
transitToNext: z.number().optional(),
transitDescription: z.string().optional(),
});
export const GenerateScheduleSchema = z.object({
items: z.array(PlanItemSchema).min(1),
summary: z.string(),
});
export const RefinePlanSchema = z.object({
days: z.array(
z.object({
date: z.string(),
items: z.array(z.record(z.string(), z.unknown())),
}),
).min(1),
});
export const SuggestAlternativesSchema = z.object({
alternatives: z.array(
z.object({
activity: z.string(),
searchQuery: z.string(),
reason: z.string().optional(),
}),
),
});
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod";
export const AvailableTimeSchema = z.object({
date: z.string().min(1),
startHour: z.number().int().min(0).max(23),
endHour: z.number().int().min(1).max(24),
}).refine((v) => v.endHour > v.startHour, { message: "endHour must be after startHour" });
export const CreatePlanSchema = z.object({
roomId: z.string().min(1, "roomId 不能为空"),
availableTime: AvailableTimeSchema,
});
export const UpdatePlanSchema = z.object({
planId: z.string().min(1, "planId 不能为空"),
action: z.string().optional(),
days: z.array(z.unknown()).optional(),
});
export const CreateRoomSchema = z.object({
restaurants: z.array(z.unknown()).min(1),
scene: z.string().optional(),
});
-158
View File
@@ -1,158 +0,0 @@
import { prisma } from "./prisma";
import { Prisma } from "@prisma/client";
import { Restaurant, SceneType } from "@/types";
const ROOM_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
export interface RoomData {
users: string[];
restaurants: Restaurant[];
likes: Record<string, string[]>;
swipeCounts: Record<string, number>;
match: string | null;
creatorId: string;
locked: boolean;
kickedUsers: string[];
scene: SceneType;
}
const ROOM_ID_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
function generateRoomId(): string {
let id = "";
for (let i = 0; i < 6; i++) {
id += ROOM_ID_CHARS[Math.floor(Math.random() * ROOM_ID_CHARS.length)];
}
return id;
}
function normalize(raw: Partial<RoomData>): RoomData {
return {
users: raw.users ?? [],
restaurants: raw.restaurants ?? [],
likes: raw.likes ?? {},
swipeCounts: raw.swipeCounts ?? {},
match: raw.match ?? null,
creatorId: raw.creatorId ?? "",
locked: raw.locked ?? false,
kickedUsers: raw.kickedUsers ?? [],
scene: raw.scene ?? "eat",
};
}
let lastCleanup = 0;
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // at most once per hour
async function cleanupExpiredRooms() {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
lastCleanup = now;
try {
const { count } = await prisma.room.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
if (count > 0) {
console.log(`Cleaned up ${count} expired room(s)`);
}
} catch (e) {
console.error("Room cleanup failed:", e);
}
}
export async function createRoom(restaurants: Restaurant[], creatorId: string, scene: SceneType = "eat"): Promise<string> {
await cleanupExpiredRooms();
const data: RoomData = {
users: [],
restaurants,
likes: {},
swipeCounts: {},
match: null,
creatorId,
locked: false,
kickedUsers: [],
scene,
};
const expiresAt = new Date(Date.now() + ROOM_TTL_MS);
const payload = JSON.stringify(data);
for (let attempts = 0; attempts < 20; attempts++) {
const roomId = generateRoomId();
try {
await prisma.room.create({
data: { id: roomId, data: payload, expiresAt },
});
return roomId;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
continue;
}
throw e;
}
}
throw new Error("无法生成唯一房间号,请稍后重试");
}
export async function getRoomData(
roomId: string,
): Promise<RoomData | null> {
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) return null;
if (room.expiresAt < new Date()) {
await prisma.room.delete({ where: { id: roomId } }).catch((e) => {
console.error(`Failed to delete expired room ${roomId}:`, e);
});
return null;
}
return normalize(JSON.parse(room.data));
}
/**
* Per-room mutex to serialize concurrent read-modify-write operations.
* SQLite doesn't support row-level locks (SELECT ... FOR UPDATE),
* so we use an application-level lock to prevent lost updates.
*/
const roomLocks = new Map<string, Promise<unknown>>();
function withRoomLock<T>(roomId: string, fn: () => Promise<T>): Promise<T> {
const prev = roomLocks.get(roomId) ?? Promise.resolve();
const next = prev.then(fn, fn);
roomLocks.set(roomId, next);
// Cleanup the lock entry when the chain settles
next.finally(() => {
if (roomLocks.get(roomId) === next) {
roomLocks.delete(roomId);
}
});
return next;
}
/**
* Atomic read-modify-write with per-room serialization.
* Uses an application-level mutex to prevent concurrent lost updates,
* since SQLite lacks row-level locking.
*/
export async function atomicUpdateRoom(
roomId: string,
updater: (data: RoomData) => RoomData,
): Promise<RoomData | null> {
return withRoomLock(roomId, () =>
prisma.$transaction(async (tx) => {
const room = await tx.room.findUnique({ where: { id: roomId } });
if (!room) return null;
const data = normalize(JSON.parse(room.data));
const updated = updater(data);
await tx.room.update({
where: { id: roomId },
data: { data: JSON.stringify(updated) },
});
return updated;
}),
);
}