c17b13b6a8
- 扩展 WeekendPlan schema: 新增 endTime 字段与 userId 索引 - PATCH /api/blindbox/plan 支持 accept/complete/expire 操作, 接受时自动计算契约结束时间 - GET /api/blindbox/plan 支持 mode 参数 (latest/pending/history) - 房间页接受契约后自动返回想法池,顶部显示"契约进行中"指示器 - 契约到期时触发浏览器通知 + 页面加载时弹出完成确认弹窗 - 新增 /achievements 成就墙页面:统计数据 + 决策记录/契约记录双标签 - 首页和个人中心新增成就墙入口
480 lines
15 KiB
TypeScript
480 lines
15 KiB
TypeScript
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<string, string[]> = {
|
|
morning: ["outdoor", "sports", "culture"],
|
|
lunch: ["dining"],
|
|
afternoon: ["entertainment", "shopping", "relaxation", "outdoor", "culture"],
|
|
dinner: ["dining"],
|
|
evening: ["entertainment", "relaxation"],
|
|
};
|
|
|
|
function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): TaggedIdea[] {
|
|
const byCategory = new Map<string, TaggedIdea[]>();
|
|
for (const idea of ideas) {
|
|
const list = byCategory.get(idea.category) || [];
|
|
list.push(idea);
|
|
byCategory.set(idea.category, list);
|
|
}
|
|
|
|
const slots: string[] = [];
|
|
if (availableHours >= 10) {
|
|
slots.push("morning", "lunch", "afternoon", "dinner", "evening");
|
|
} else if (availableHours >= 7) {
|
|
slots.push("morning", "lunch", "afternoon", "evening");
|
|
} else if (availableHours >= 5) {
|
|
slots.push("lunch", "afternoon", "evening");
|
|
} else {
|
|
slots.push("afternoon", "evening");
|
|
}
|
|
|
|
const selected: TaggedIdea[] = [];
|
|
const usedIds = new Set<string>();
|
|
|
|
for (const slot of slots) {
|
|
const preferredCategories = SLOT_CATEGORY_MAP[slot] || [];
|
|
|
|
let picked: TaggedIdea | null = null;
|
|
for (const cat of preferredCategories) {
|
|
const pool = (byCategory.get(cat) || []).filter((i) => !usedIds.has(i.id));
|
|
if (pool.length > 0) {
|
|
picked = pool[Math.floor(Math.random() * pool.length)];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!picked) {
|
|
const remaining = ideas.filter((i) => !usedIds.has(i.id));
|
|
if (remaining.length > 0) {
|
|
picked = remaining[Math.floor(Math.random() * remaining.length)];
|
|
}
|
|
}
|
|
|
|
if (picked) {
|
|
selected.push(picked);
|
|
usedIds.add(picked.id);
|
|
}
|
|
}
|
|
|
|
return selected;
|
|
}
|
|
|
|
async function searchPois(
|
|
query: string,
|
|
searchType: string,
|
|
anchorLat: number,
|
|
anchorLng: number,
|
|
): Promise<{ name: string; address: string; lat: number; lng: number; rating?: number }[]> {
|
|
const apiKey = requireAmapApiKey();
|
|
|
|
if (searchType === "category") {
|
|
const url = new URL("https://restapi.amap.com/v5/place/around");
|
|
url.searchParams.set("key", apiKey);
|
|
url.searchParams.set("location", `${anchorLng},${anchorLat}`);
|
|
url.searchParams.set("keywords", query);
|
|
url.searchParams.set("radius", "5000");
|
|
url.searchParams.set("show_fields", "business");
|
|
url.searchParams.set("page_size", "8");
|
|
|
|
const res = await fetch(url.toString());
|
|
const data = await res.json();
|
|
if (data.status !== "1" || !data.pois?.length) return [];
|
|
return mapPois(data.pois);
|
|
}
|
|
|
|
// Text/brand search — bias results to the room's location
|
|
const url = new URL("https://restapi.amap.com/v5/place/text");
|
|
url.searchParams.set("key", apiKey);
|
|
url.searchParams.set("keywords", query);
|
|
url.searchParams.set("location", `${anchorLng},${anchorLat}`);
|
|
url.searchParams.set("show_fields", "business");
|
|
url.searchParams.set("page_size", "8");
|
|
|
|
const res = await fetch(url.toString());
|
|
const data = await res.json();
|
|
if (data.status !== "1" || !data.pois?.length) return [];
|
|
return mapPois(data.pois);
|
|
}
|
|
|
|
function mapPois(
|
|
pois: { name: string; address?: string; location?: string; business?: { rating?: string } }[],
|
|
) {
|
|
return pois
|
|
.filter((p) => p.location)
|
|
.map((p) => {
|
|
const [lng, lat] = (p.location ?? "0,0").split(",").map(Number);
|
|
const ratingStr = p.business?.rating;
|
|
return {
|
|
name: p.name,
|
|
address: p.address || "",
|
|
lat,
|
|
lng,
|
|
rating: ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || undefined : undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
export const POST = apiHandler(async (req) => {
|
|
const { roomId, userId, availableTime } = await req.json();
|
|
|
|
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<string>();
|
|
for (const dayConfig of dayConfigs) {
|
|
const remaining = taggedIdeas.filter((i) => !usedIds.has(i.id));
|
|
if (remaining.length < 2) break;
|
|
const selected = selectIdeasForSlots(remaining, dayConfig.endHour - dayConfig.startHour);
|
|
for (const idea of selected) usedIds.add(idea.id);
|
|
dayIdeas.push(selected);
|
|
}
|
|
// Trim to actual days generated (may be fewer than requested for "整个周末")
|
|
const actualDayConfigs = dayConfigs.slice(0, dayIdeas.length);
|
|
|
|
const allSelected = dayIdeas.flat();
|
|
if (allSelected.length === 0) {
|
|
throw new ApiError("无法从想法池中选出合适的活动", 400);
|
|
}
|
|
|
|
// Deduplicate search queries across all days
|
|
const uniqueByQuery = new Map<string, TaggedIdea>();
|
|
for (const idea of allSelected) {
|
|
if (!uniqueByQuery.has(idea.searchQuery)) uniqueByQuery.set(idea.searchQuery, idea);
|
|
}
|
|
|
|
// Phase 1: search brand/place type queries in parallel
|
|
const brandPlaceQueries = [...uniqueByQuery.values()].filter((i) => i.searchType !== "category");
|
|
|
|
const searchResults = await Promise.all(
|
|
brandPlaceQueries.map(async (idea) => {
|
|
try {
|
|
const pois = await searchPois(idea.searchQuery, idea.searchType, room.lat!, room.lng!);
|
|
return { query: idea.searchQuery, pois };
|
|
} catch {
|
|
return { query: idea.searchQuery, pois: [] };
|
|
}
|
|
}),
|
|
);
|
|
|
|
const candidates: ScheduleContext["candidates"] = {};
|
|
for (const result of searchResults) {
|
|
candidates[result.query] = result.pois;
|
|
}
|
|
|
|
// Phase 2: category-type queries anchored to centroid of found POIs
|
|
const catQueries = [...uniqueByQuery.values()].filter((i) => i.searchType === "category");
|
|
if (catQueries.length > 0) {
|
|
const allPois = Object.values(candidates).flat();
|
|
let anchorLat = room.lat;
|
|
let anchorLng = room.lng;
|
|
if (allPois.length > 0) {
|
|
anchorLat = allPois.reduce((s, p) => s + p.lat, 0) / allPois.length;
|
|
anchorLng = allPois.reduce((s, p) => s + p.lng, 0) / allPois.length;
|
|
}
|
|
|
|
const catResults = await Promise.all(
|
|
catQueries.map(async (idea) => {
|
|
try {
|
|
const pois = await searchPois(idea.searchQuery, idea.searchType, anchorLat, anchorLng);
|
|
return { query: idea.searchQuery, pois };
|
|
} catch {
|
|
return { query: idea.searchQuery, pois: [] };
|
|
}
|
|
}),
|
|
);
|
|
|
|
for (const result of catResults) {
|
|
candidates[result.query] = result.pois;
|
|
}
|
|
}
|
|
|
|
// Generate schedule for each day (parallel AI calls)
|
|
const schedules = await Promise.all(
|
|
actualDayConfigs.map((dayConfig, idx) => {
|
|
const ideas = dayIdeas[idx];
|
|
const ctx: ScheduleContext = {
|
|
ideas: ideas.map((i) => ({
|
|
content: i.content,
|
|
category: i.category,
|
|
timeSlot: i.timeSlot,
|
|
estimatedMinutes: i.estimatedMinutes,
|
|
searchQuery: i.searchQuery,
|
|
searchType: i.searchType,
|
|
})),
|
|
candidates,
|
|
userLocation: { lat: room.lat!, lng: room.lng! },
|
|
availableTime: dayConfig,
|
|
};
|
|
return generateSchedule(ctx);
|
|
}),
|
|
);
|
|
|
|
const days = schedules
|
|
.map((schedule, idx) =>
|
|
schedule
|
|
? { date: actualDayConfigs[idx].date, items: schedule.items, summary: schedule.summary }
|
|
: null,
|
|
)
|
|
.filter((d) => d !== null);
|
|
|
|
if (days.length === 0) {
|
|
throw new ApiError("AI 规划失败,请稍后重试", 500);
|
|
}
|
|
|
|
const plan = await prisma.weekendPlan.create({
|
|
data: {
|
|
roomId,
|
|
userId,
|
|
planData: JSON.stringify({
|
|
days,
|
|
selectedIdeaIds: allSelected.map((i) => i.id),
|
|
}),
|
|
},
|
|
});
|
|
|
|
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);
|
|
});
|