feat: AI 周末行程规划 — DeepSeek 智能排期 + 高德 POI + 多日翻页

- 接入 DeepSeek API,提交想法时自动 AI 打标(品类/时段/时长/搜索策略)
- 新增行程规划 API:智能选取想法 → 高德 POI 搜索 → AI 生成最优行程
- 支持多日计划("整个周末"拆分周六/周日,并行 AI 调用)
- 行程展示逐日翻页,时间线可滚动,操作按钮固定底部
- 分享卡适配多日格式,支持图片保存与原生分享
- Prisma schema 新增 WeekendPlan 模型及 BlindBoxIdea AI 标签字段
- Jenkinsfile 集成 DEEPSEEK_API_KEY 环境变量
This commit is contained in:
2026-02-27 01:51:47 +08:00
parent 8c6da410ca
commit 9c680ec11e
16 changed files with 1721 additions and 70 deletions
+310
View File
@@ -0,0 +1,310 @@
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, avoiding duplicates across days when possible
const dayIdeas: TaggedIdea[][] = [];
const usedIds = new Set<string>();
for (const dayConfig of dayConfigs) {
const remaining = taggedIdeas.filter((i) => !usedIds.has(i.id));
const pool = remaining.length >= 2 ? remaining : taggedIdeas;
const selected = selectIdeasForSlots(pool, dayConfig.endHour - dayConfig.startHour);
for (const idea of selected) usedIds.add(idea.id);
dayIdeas.push(selected);
}
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(
dayConfigs.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: dayConfigs[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,
});
});
+42 -1
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getRoomByCode } from "@/lib/blindbox";
import { getRoomByCode, requireMembership } from "@/lib/blindbox";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
export const GET = apiHandler(async (_req, { params }) => {
@@ -14,6 +14,9 @@ export const GET = apiHandler(async (_req, { params }) => {
code: room.code,
name: room.name,
creatorId: room.creatorId,
city: room.city,
lat: room.lat,
lng: room.lng,
poolCount: room._count.ideas,
members: room.members.map((m) => ({
...m.user,
@@ -22,6 +25,44 @@ export const GET = apiHandler(async (_req, { params }) => {
});
});
export const PATCH = apiHandler(async (req, { params }) => {
const { code } = await params;
const { userId, city, lat, lng } = await req.json();
requireUserId(userId);
const room = await prisma.blindBoxRoom.findUnique({
where: { code: code.toUpperCase() },
});
if (!room) throw new ApiError("房间不存在", 404);
await requireMembership(room.id, userId);
const numLat = Number(lat);
const numLng = Number(lng);
if (
!Number.isFinite(numLat) || !Number.isFinite(numLng) ||
numLat < -90 || numLat > 90 || numLng < -180 || numLng > 180
) {
throw new ApiError("位置坐标无效");
}
const updated = await prisma.blindBoxRoom.update({
where: { id: room.id },
data: {
city: typeof city === "string" ? city.trim() : null,
lat: numLat,
lng: numLng,
},
});
return NextResponse.json({
city: updated.city,
lat: updated.lat,
lng: updated.lng,
});
});
export const DELETE = apiHandler(async (req, { params }) => {
const { code } = await params;
const { userId } = await req.json();
+57 -3
View File
@@ -3,6 +3,9 @@ import { prisma } from "@/lib/prisma";
import { requireMembership } from "@/lib/blindbox";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
import { validateIdeaContent, requireString } from "@/lib/validation";
import { tagIdea } from "@/lib/ai";
const TAG_TIMEOUT_MS = 3000;
export const POST = apiHandler(async (req) => {
const { roomId, userId, content } = await req.json();
@@ -17,7 +20,29 @@ export const POST = apiHandler(async (req) => {
data: { roomId, userId, content: trimmedContent },
});
return NextResponse.json({ id: idea.id }, { status: 201 });
const tags = await Promise.race([
tagIdea(trimmedContent),
new Promise<null>((resolve) => setTimeout(() => resolve(null), TAG_TIMEOUT_MS)),
]);
if (tags) {
await prisma.blindBoxIdea.update({
where: { id: idea.id },
data: {
category: tags.category,
timeSlot: tags.timeSlot,
estimatedMinutes: tags.estimatedMinutes,
outdoor: tags.outdoor,
searchQuery: tags.searchQuery,
searchType: tags.searchType,
},
});
}
return NextResponse.json(
{ id: idea.id, ...tags && { tags } },
{ status: 201 },
);
});
export const GET = apiHandler(async (req) => {
@@ -33,7 +58,17 @@ export const GET = apiHandler(async (req) => {
prisma.blindBoxIdea.findMany({
where: { roomId, userId, status: "in_pool" },
orderBy: { createdAt: "desc" },
select: { id: true, content: true, createdAt: true },
select: {
id: true,
content: true,
createdAt: true,
category: true,
timeSlot: true,
estimatedMinutes: true,
outdoor: true,
searchQuery: true,
searchType: true,
},
}),
prisma.blindBoxIdea.findMany({
where: { roomId, status: "drawn" },
@@ -62,7 +97,26 @@ export const PUT = apiHandler(async (req) => {
if (count === 0) throw new ApiError("想法不存在、已被抽中或无权编辑", 404);
return NextResponse.json({ id: ideaId, content: trimmedContent });
const tags = await Promise.race([
tagIdea(trimmedContent),
new Promise<null>((resolve) => setTimeout(() => resolve(null), TAG_TIMEOUT_MS)),
]);
if (tags) {
await prisma.blindBoxIdea.updateMany({
where: { id: ideaId, userId, status: "in_pool" },
data: {
category: tags.category,
timeSlot: tags.timeSlot,
estimatedMinutes: tags.estimatedMinutes,
outdoor: tags.outdoor,
searchQuery: tags.searchQuery,
searchType: tags.searchType,
},
});
}
return NextResponse.json({ id: ideaId, content: trimmedContent, ...tags && { tags } });
});
export const DELETE = apiHandler(async (req) => {