import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { requireMembership } from "@/lib/blindbox"; import { apiHandler, ApiError, requireUserId } from "@/lib/api"; import { requireAmapApiKey } from "@/lib/amap"; import { generateSchedule, type ScheduleContext } from "@/lib/ai"; interface AvailableTime { date: string; startHour: number; endHour: number; } interface TaggedIdea { id: string; content: string; category: string; timeSlot: string; estimatedMinutes: number; searchQuery: string; searchType: string; } const SLOT_CATEGORY_MAP: Record = { morning: ["outdoor", "sports", "culture"], lunch: ["dining"], afternoon: ["entertainment", "shopping", "relaxation", "outdoor", "culture"], dinner: ["dining"], evening: ["entertainment", "relaxation"], }; function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): TaggedIdea[] { const byCategory = new Map(); for (const idea of ideas) { const list = byCategory.get(idea.category) || []; list.push(idea); byCategory.set(idea.category, list); } const slots: string[] = []; if (availableHours >= 10) { slots.push("morning", "lunch", "afternoon", "dinner", "evening"); } else if (availableHours >= 7) { slots.push("morning", "lunch", "afternoon", "evening"); } else if (availableHours >= 5) { slots.push("lunch", "afternoon", "evening"); } else { slots.push("afternoon", "evening"); } const selected: TaggedIdea[] = []; const usedIds = new Set(); for (const slot of slots) { const preferredCategories = SLOT_CATEGORY_MAP[slot] || []; let picked: TaggedIdea | null = null; for (const cat of preferredCategories) { const pool = (byCategory.get(cat) || []).filter((i) => !usedIds.has(i.id)); if (pool.length > 0) { picked = pool[Math.floor(Math.random() * pool.length)]; break; } } if (!picked) { const remaining = ideas.filter((i) => !usedIds.has(i.id)); if (remaining.length > 0) { picked = remaining[Math.floor(Math.random() * remaining.length)]; } } if (picked) { selected.push(picked); usedIds.add(picked.id); } } return selected; } async function searchPois( query: string, searchType: string, anchorLat: number, anchorLng: number, ): Promise<{ name: string; address: string; lat: number; lng: number; rating?: number }[]> { const apiKey = requireAmapApiKey(); if (searchType === "category") { const url = new URL("https://restapi.amap.com/v5/place/around"); url.searchParams.set("key", apiKey); url.searchParams.set("location", `${anchorLng},${anchorLat}`); url.searchParams.set("keywords", query); url.searchParams.set("radius", "5000"); url.searchParams.set("show_fields", "business"); url.searchParams.set("page_size", "8"); const res = await fetch(url.toString()); const data = await res.json(); if (data.status !== "1" || !data.pois?.length) return []; return mapPois(data.pois); } // Text/brand search — bias results to the room's location const url = new URL("https://restapi.amap.com/v5/place/text"); url.searchParams.set("key", apiKey); url.searchParams.set("keywords", query); url.searchParams.set("location", `${anchorLng},${anchorLat}`); url.searchParams.set("show_fields", "business"); url.searchParams.set("page_size", "8"); const res = await fetch(url.toString()); const data = await res.json(); if (data.status !== "1" || !data.pois?.length) return []; return mapPois(data.pois); } function mapPois( pois: { name: string; address?: string; location?: string; business?: { rating?: string } }[], ) { return pois .filter((p) => p.location) .map((p) => { const [lng, lat] = (p.location ?? "0,0").split(",").map(Number); const ratingStr = p.business?.rating; return { name: p.name, address: p.address || "", lat, lng, rating: ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || undefined : undefined, }; }); } export const POST = apiHandler(async (req) => { const { roomId, userId, availableTime } = await req.json(); requireUserId(userId); if (!roomId) throw new ApiError("roomId 不能为空"); await requireMembership(roomId, userId); const at = availableTime as AvailableTime; if ( !at?.date || typeof at.startHour !== "number" || typeof at.endHour !== "number" || at.endHour <= at.startHour ) { throw new ApiError("请选择有效的可用时间"); } const room = await prisma.blindBoxRoom.findUnique({ where: { id: roomId } }); if (!room) throw new ApiError("房间不存在", 404); if (!room.lat || !room.lng) { throw new ApiError("请先设置房间位置", 400); } const allIdeas = await prisma.blindBoxIdea.findMany({ where: { roomId, status: "in_pool", category: { not: null } }, select: { id: true, content: true, category: true, timeSlot: true, estimatedMinutes: true, searchQuery: true, searchType: true, }, }); const taggedIdeas: TaggedIdea[] = allIdeas.filter( (i): i is TaggedIdea => !!i.category && !!i.timeSlot && !!i.searchQuery && !!i.searchType && typeof i.estimatedMinutes === "number", ); if (taggedIdeas.length < 2) { throw new ApiError("盒子里至少需要 2 个已标记的想法才能生成计划", 400); } // Split into day configs — "整个周末" generates two separate days const dayConfigs: AvailableTime[] = at.date === "整个周末" ? [ { date: "周六", startHour: at.startHour, endHour: at.endHour }, { date: "周日", startHour: at.startHour, endHour: at.endHour }, ] : [at]; // Select ideas per day — skip extra days when ideas run out const dayIdeas: TaggedIdea[][] = []; const usedIds = new Set(); for (const dayConfig of dayConfigs) { const remaining = taggedIdeas.filter((i) => !usedIds.has(i.id)); if (remaining.length < 2) break; const selected = selectIdeasForSlots(remaining, dayConfig.endHour - dayConfig.startHour); for (const idea of selected) usedIds.add(idea.id); dayIdeas.push(selected); } // Trim to actual days generated (may be fewer than requested for "整个周末") const actualDayConfigs = dayConfigs.slice(0, dayIdeas.length); const allSelected = dayIdeas.flat(); if (allSelected.length === 0) { throw new ApiError("无法从想法池中选出合适的活动", 400); } // Deduplicate search queries across all days const uniqueByQuery = new Map(); for (const idea of allSelected) { if (!uniqueByQuery.has(idea.searchQuery)) uniqueByQuery.set(idea.searchQuery, idea); } // Phase 1: search brand/place type queries in parallel const brandPlaceQueries = [...uniqueByQuery.values()].filter((i) => i.searchType !== "category"); const searchResults = await Promise.all( brandPlaceQueries.map(async (idea) => { try { const pois = await searchPois(idea.searchQuery, idea.searchType, room.lat!, room.lng!); return { query: idea.searchQuery, pois }; } catch { return { query: idea.searchQuery, pois: [] }; } }), ); const candidates: ScheduleContext["candidates"] = {}; for (const result of searchResults) { candidates[result.query] = result.pois; } // Phase 2: category-type queries anchored to centroid of found POIs const catQueries = [...uniqueByQuery.values()].filter((i) => i.searchType === "category"); if (catQueries.length > 0) { const allPois = Object.values(candidates).flat(); let anchorLat = room.lat; let anchorLng = room.lng; if (allPois.length > 0) { anchorLat = allPois.reduce((s, p) => s + p.lat, 0) / allPois.length; anchorLng = allPois.reduce((s, p) => s + p.lng, 0) / allPois.length; } const catResults = await Promise.all( catQueries.map(async (idea) => { try { const pois = await searchPois(idea.searchQuery, idea.searchType, anchorLat, anchorLng); return { query: idea.searchQuery, pois }; } catch { return { query: idea.searchQuery, pois: [] }; } }), ); for (const result of catResults) { candidates[result.query] = result.pois; } } // Generate schedule for each day (parallel AI calls) const schedules = await Promise.all( actualDayConfigs.map((dayConfig, idx) => { const ideas = dayIdeas[idx]; const ctx: ScheduleContext = { ideas: ideas.map((i) => ({ content: i.content, category: i.category, timeSlot: i.timeSlot, estimatedMinutes: i.estimatedMinutes, searchQuery: i.searchQuery, searchType: i.searchType, })), candidates, userLocation: { lat: room.lat!, lng: room.lng! }, availableTime: dayConfig, }; return generateSchedule(ctx); }), ); const days = schedules .map((schedule, idx) => schedule ? { date: actualDayConfigs[idx].date, items: schedule.items, summary: schedule.summary } : null, ) .filter((d) => d !== null); if (days.length === 0) { throw new ApiError("AI 规划失败,请稍后重试", 500); } const plan = await prisma.weekendPlan.create({ data: { roomId, userId, planData: JSON.stringify({ days, selectedIdeaIds: allSelected.map((i) => i.id), }), }, }); return NextResponse.json({ id: plan.id, days, createdAt: plan.createdAt, }); }); /** * Map "周六"/"周日" to the next occurrence of that weekday from a reference date. * Returns a Date at 00:00 of that day. */ function nextWeekday(dayLabel: string, from: Date): Date { const targetDow = dayLabel === "周日" ? 0 : 6; // Sunday=0, Saturday=6 const d = new Date(from); d.setHours(0, 0, 0, 0); const diff = (targetDow - d.getDay() + 7) % 7; d.setDate(d.getDate() + (diff === 0 ? 0 : diff)); return d; } function computeEndTime(planData: string, now: Date): Date | null { try { const parsed = JSON.parse(planData); const days = parsed.days as { date: string; items: { time: string; duration: number }[] }[]; if (!days?.length) return null; const lastDay = days[days.length - 1]; const lastItem = lastDay.items[lastDay.items.length - 1]; if (!lastItem) return null; const base = nextWeekday(lastDay.date, now); const [h, m] = lastItem.time.split(":").map(Number); base.setHours(h, m, 0, 0); base.setMinutes(base.getMinutes() + (lastItem.duration || 60)); // If computed end time is in the past, it's for next week if (base.getTime() < now.getTime()) { base.setDate(base.getDate() + 7); } return base; } catch { return null; } } export const PATCH = apiHandler(async (req) => { const { planId, userId, action } = await req.json(); requireUserId(userId); if (!planId) throw new ApiError("planId 不能为空"); const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } }); if (!plan) throw new ApiError("计划不存在", 404); if (plan.userId !== userId) throw new ApiError("只能操作自己的计划", 403); const act = action || "accept"; if (act === "accept") { if (plan.status !== "active") throw new ApiError("该计划无法接受", 400); const endTime = computeEndTime(plan.planData, new Date()); await prisma.weekendPlan.update({ where: { id: planId }, data: { status: "accepted", endTime }, }); return NextResponse.json({ ok: true, endTime }); } if (act === "complete" || act === "expire") { if (plan.status !== "accepted") throw new ApiError("只能更新已接受的计划", 400); await prisma.weekendPlan.update({ where: { id: planId }, data: { status: act === "complete" ? "completed" : "expired" }, }); return NextResponse.json({ ok: true }); } throw new ApiError("无效的操作", 400); }); export const GET = apiHandler(async (req) => { const { searchParams } = new URL(req.url); const mode = searchParams.get("mode") || "latest"; const userId = searchParams.get("userId"); requireUserId(userId); if (mode === "latest") { const roomId = searchParams.get("roomId"); if (!roomId) throw new ApiError("roomId 不能为空"); const plan = await prisma.weekendPlan.findFirst({ where: { roomId, userId: userId!, status: "accepted" }, orderBy: { createdAt: "desc" }, select: { id: true, planData: true, endTime: true, createdAt: true }, }); if (!plan) return NextResponse.json({ plan: null }); const parsed = JSON.parse(plan.planData); return NextResponse.json({ plan: { id: plan.id, days: parsed.days, endTime: plan.endTime, createdAt: plan.createdAt }, }); } if (mode === "pending") { const plans = await prisma.weekendPlan.findMany({ where: { userId: userId!, status: "accepted", endTime: { not: null, lt: new Date() }, }, orderBy: { createdAt: "desc" }, select: { id: true, planData: true, roomId: true, createdAt: true }, take: 5, }); const result = await Promise.all( plans.map(async (p) => { const room = await prisma.blindBoxRoom.findUnique({ where: { id: p.roomId }, select: { name: true, code: true }, }); const parsed = JSON.parse(p.planData); const days = parsed.days as { date: string; items: { activity: string }[] }[]; return { id: p.id, roomName: room?.name ?? "未知房间", roomCode: room?.code ?? "", date: days.map((d) => d.date).join(" + "), activities: days.flatMap((d) => d.items.map((i) => i.activity)), createdAt: p.createdAt, }; }), ); return NextResponse.json({ pending: result }); } if (mode === "history") { const plans = await prisma.weekendPlan.findMany({ where: { userId: userId!, status: { in: ["completed", "expired"] }, }, orderBy: { createdAt: "desc" }, select: { id: true, planData: true, status: true, roomId: true, createdAt: true }, take: 50, }); const result = await Promise.all( plans.map(async (p) => { const room = await prisma.blindBoxRoom.findUnique({ where: { id: p.roomId }, select: { name: true, code: true }, }); const parsed = JSON.parse(p.planData); const days = parsed.days as { date: string; items: { activity: string }[] }[]; return { id: p.id, status: p.status, roomName: room?.name ?? "未知房间", roomCode: room?.code ?? "", date: days.map((d) => d.date).join(" + "), dayCount: days.length, activities: days.flatMap((d) => d.items.map((i) => i.activity)), createdAt: p.createdAt, }; }), ); return NextResponse.json({ history: result }); } throw new ApiError("无效的 mode 参数", 400); });