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
+4 -710
View File
@@ -1,710 +1,4 @@
/**
* Shared plan generation logic for blindbox weekend plans.
* Supports optional progress callback for streaming UX.
*
* 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(),
};
}
// Re-export from refactored modules for backward compatibility
export type { PlanGenAvailableTime, PlanGenResult } from "./plan/index";
export { runPlanGeneration } from "./plan/index";
export { PLAN_PROGRESS_MESSAGES } from "./plan/legacyPlan";