refactor(P0): JWT 认证、并发安全、错误日志三项安全加固

- 新增 JWT httpOnly cookie 认证链路 (jose),登录/注册签发 token,
  所有用户和盲盒 API 改为从 cookie 提取 userId,不再信任客户端传值
- 新增 /api/auth/logout 端点清除认证 cookie
- GET /api/user 区分 owner/非 owner,非 owner 不暴露 email
- atomicUpdateRoom 新增 per-room 应用层互斥锁,防止 SQLite 下并发 lost update
- 修复 getRoomData 中 fire-and-forget delete 改为 await
- 37 个静默 catch 块跨 17 个文件添加 console.error 日志
- 新增 REFACTOR_PLAN.md 全景分析文档
This commit is contained in:
2026-03-02 17:24:26 +08:00
parent 99120a7042
commit ce76980fe5
41 changed files with 528 additions and 144 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ export default function AchievementsPage() {
setStats(data.stats);
setDecisions(data.decisions);
setContracts(data.contracts);
} catch { /* ignore */ }
} catch (e) { console.error("AchievementsPage: fetch failed:", e); }
finally { setLoading(false); }
})();
}, [router]);
+5 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { apiHandler, ApiError } from "@/lib/api";
import { signToken, setAuthCookie } from "@/lib/auth";
export const POST = apiHandler(async (req) => {
const { username, password } = await req.json();
@@ -14,9 +15,12 @@ export const POST = apiHandler(async (req) => {
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) throw new ApiError("用户名或密码错误", 401);
return NextResponse.json({
const token = await signToken(user.id);
const res = NextResponse.json({
id: user.id,
username: user.username,
avatar: user.avatar,
});
return setAuthCookie(res, token);
});
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { clearAuthCookie } from "@/lib/auth";
export async function POST() {
const res = NextResponse.json({ ok: true });
return clearAuthCookie(res);
}
+5 -1
View File
@@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client";
import bcrypt from "bcryptjs";
import { apiHandler, ApiError } from "@/lib/api";
import { validateUsername, validatePassword } from "@/lib/validation";
import { signToken, setAuthCookie } from "@/lib/auth";
export const POST = apiHandler(async (req) => {
const { username, password, avatar } = await req.json();
@@ -24,11 +25,14 @@ export const POST = apiHandler(async (req) => {
},
});
return NextResponse.json({
const token = await signToken(user.id);
const res = NextResponse.json({
id: user.id,
username: user.username,
avatar: user.avatar,
});
return setAuthCookie(res, token);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
throw new ApiError("用户名已被注册", 409);
+4 -3
View File
@@ -1,12 +1,13 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireMembership } from "@/lib/blindbox";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
import { apiHandler, ApiError } from "@/lib/api";
import { getAuthUserId } from "@/lib/auth";
export const POST = apiHandler(async (req) => {
const { roomId, userId } = await req.json();
const userId = await getAuthUserId(req);
const { roomId } = await req.json();
requireUserId(userId);
if (!roomId || typeof roomId !== "string") throw new ApiError("roomId 不能为空");
await requireMembership(roomId, userId);
+4 -3
View File
@@ -1,10 +1,11 @@
import { NextResponse } from "next/server";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
import { apiHandler, ApiError } from "@/lib/api";
import { refinePlan } from "@/lib/ai";
import { getAuthUserId } from "@/lib/auth";
export const POST = apiHandler(async (req) => {
const { userId, instruction, days } = await req.json();
requireUserId(userId);
await getAuthUserId(req);
const { instruction, days } = await req.json();
if (!instruction?.trim()) throw new ApiError("指令不能为空", 400);
if (!Array.isArray(days) || days.length === 0) throw new ApiError("days 无效", 400);
+13 -12
View File
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server";
import { requireMembership } from "@/lib/blindbox";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
import { apiHandler, ApiError } from "@/lib/api";
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
import { getAuthUserId } from "@/lib/auth";
interface AvailableTime {
date: string;
@@ -10,9 +11,9 @@ interface AvailableTime {
}
export const POST = apiHandler(async (req) => {
const { roomId, userId, availableTime } = await req.json();
const userId = await getAuthUserId(req);
const { roomId, availableTime } = await req.json();
requireUserId(userId);
if (!roomId) throw new ApiError("roomId 不能为空");
await requireMembership(roomId, userId);
@@ -27,7 +28,7 @@ export const POST = apiHandler(async (req) => {
throw new ApiError("请选择有效的可用时间");
}
const result = await runPlanGeneration(roomId, userId!, at);
const result = await runPlanGeneration(roomId, userId, at);
return NextResponse.json({
id: result.id,
@@ -69,14 +70,15 @@ function computeEndTime(planData: string, now: Date): Date | null {
}
return base;
} catch {
} catch (e) {
console.error("computeEndTime failed:", e);
return null;
}
}
export const PATCH = apiHandler(async (req) => {
const { planId, userId, action, days } = await req.json();
requireUserId(userId);
const userId = await getAuthUserId(req);
const { planId, action, days } = await req.json();
if (!planId) throw new ApiError("planId 不能为空");
const { prisma } = await import("@/lib/prisma");
@@ -129,10 +131,9 @@ export const PATCH = apiHandler(async (req) => {
});
export const GET = apiHandler(async (req) => {
const userId = await getAuthUserId(req);
const { searchParams } = new URL(req.url);
const mode = searchParams.get("mode") || "latest";
const userId = searchParams.get("userId");
requireUserId(userId);
const { prisma } = await import("@/lib/prisma");
@@ -141,7 +142,7 @@ export const GET = apiHandler(async (req) => {
if (!roomId) throw new ApiError("roomId 不能为空");
const plan = await prisma.weekendPlan.findFirst({
where: { roomId, userId: userId!, status: "accepted" },
where: { roomId, userId, status: "accepted" },
orderBy: { createdAt: "desc" },
select: { id: true, planData: true, endTime: true, createdAt: true },
});
@@ -156,7 +157,7 @@ export const GET = apiHandler(async (req) => {
if (mode === "pending") {
const plans = await prisma.weekendPlan.findMany({
where: {
userId: userId!,
userId,
status: "accepted",
endTime: { not: null, lt: new Date() },
},
@@ -190,7 +191,7 @@ export const GET = apiHandler(async (req) => {
if (mode === "history") {
const plans = await prisma.weekendPlan.findMany({
where: {
userId: userId!,
userId,
status: { in: ["completed", "expired"] },
},
orderBy: { createdAt: "desc" },
+8 -6
View File
@@ -1,23 +1,24 @@
import { NextRequest } from "next/server";
import { requireMembership } from "@/lib/blindbox";
import { requireUserId } from "@/lib/api";
import { runPlanGeneration } from "@/lib/blindboxPlanGen";
import { getAuthUserId } from "@/lib/auth";
function encodeSSE(event: string, data: string): string {
return `event: ${event}\ndata: ${data}\n\n`;
}
export async function POST(req: Request): Promise<Response> {
export async function POST(req: NextRequest): Promise<Response> {
let roomId: string;
let userId: string;
let availableTime: { date: string; startHour: number; endHour: number };
try {
userId = await getAuthUserId(req);
const body = await req.json();
roomId = body.roomId;
userId = body.userId;
availableTime = body.availableTime;
requireUserId(userId);
if (!roomId) {
return new Response(
JSON.stringify({ error: "roomId 不能为空" }),
@@ -41,8 +42,9 @@ export async function POST(req: Request): Promise<Response> {
}
} catch (e) {
const message = e instanceof Error ? e.message : "请求参数错误";
const status = e instanceof Error && "status" in e ? (e as { status: number }).status : 400;
return new Response(JSON.stringify({ error: message }), {
status: 400,
status,
headers: { "Content-Type": "application/json" },
});
}
@@ -55,7 +57,7 @@ export async function POST(req: Request): Promise<Response> {
};
try {
const result = await runPlanGeneration(roomId!, userId!, availableTime!, (message) => {
const result = await runPlanGeneration(roomId, userId, availableTime, (message) => {
push("status", message);
});
push("plan", JSON.stringify({ id: result.id, days: result.days, createdAt: result.createdAt }));
@@ -33,7 +33,7 @@ export const POST = apiHandler(async (req) => {
reason: alt.reason,
};
}
} catch { /* ignore, use fallback */ }
} catch (e) { console.error("suggest-item: POI search failed, using fallback:", e); }
return {
activity: alt.activity,
poi: alt.searchQuery,
+4 -3
View File
@@ -1,13 +1,14 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireMembership } from "@/lib/blindbox";
import { apiHandler, requireUserId } from "@/lib/api";
import { apiHandler } from "@/lib/api";
import { getAuthUserId } from "@/lib/auth";
import { tagIdea } from "@/lib/ai";
export const POST = apiHandler(async (req) => {
const { roomId, userId } = await req.json();
const userId = await getAuthUserId(req);
const { roomId } = await req.json();
requireUserId(userId);
await requireMembership(roomId, userId);
// Find all untagged ideas in this room (any member's ideas)
+5 -7
View File
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getRoomByCode, requireMembership } from "@/lib/blindbox";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
import { apiHandler, ApiError } from "@/lib/api";
import { getAuthUserId } from "@/lib/auth";
export const GET = apiHandler(async (_req, { params }) => {
const { code } = await params;
@@ -27,10 +28,9 @@ export const GET = apiHandler(async (_req, { params }) => {
});
export const PATCH = apiHandler(async (req, { params }) => {
const userId = await getAuthUserId(req);
const { code } = await params;
const { userId, city, address, lat, lng } = await req.json();
requireUserId(userId);
const { city, address, lat, lng } = await req.json();
const room = await prisma.blindBoxRoom.findUnique({
where: { code: code.toUpperCase() },
@@ -67,10 +67,8 @@ export const PATCH = apiHandler(async (req, { params }) => {
});
export const DELETE = apiHandler(async (req, { params }) => {
const userId = await getAuthUserId(req);
const { code } = await params;
const { userId } = await req.json();
requireUserId(userId);
const room = await prisma.blindBoxRoom.findUnique({
where: { code: code.toUpperCase() },
+4 -3
View File
@@ -1,11 +1,12 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
import { apiHandler, ApiError } from "@/lib/api";
import { getAuthUserId } from "@/lib/auth";
export const POST = apiHandler(async (req) => {
const { userId, code } = await req.json();
const userId = await getAuthUserId(req);
const { code } = await req.json();
requireUserId(userId);
if (!code || typeof code !== "string") throw new ApiError("请输入房间号");
const room = await prisma.blindBoxRoom.findUnique({
+4 -4
View File
@@ -1,13 +1,13 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { generateUniqueRoomCode } from "@/lib/blindbox";
import { apiHandler, requireUserId, requireUser } from "@/lib/api";
import { apiHandler, requireUser } from "@/lib/api";
import { validateRoomName } from "@/lib/validation";
import { getAuthUserId } from "@/lib/auth";
export const POST = apiHandler(async (req) => {
const { userId, name } = await req.json();
requireUserId(userId);
const userId = await getAuthUserId(req);
const { name } = await req.json();
const roomName = validateRoomName(name);
+3 -2
View File
@@ -1,9 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { apiHandler, requireUserId } from "@/lib/api";
import { apiHandler } from "@/lib/api";
import { getAuthUserId } from "@/lib/auth";
export const GET = apiHandler(async (req) => {
const userId = requireUserId(req.nextUrl.searchParams.get("userId"));
const userId = await getAuthUserId(req);
const memberships = await prisma.blindBoxMember.findMany({
where: { userId },
+10 -9
View File
@@ -1,9 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireMembership } from "@/lib/blindbox";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
import { apiHandler, ApiError } from "@/lib/api";
import { validateIdeaContent, requireString } from "@/lib/validation";
import { tagIdea } from "@/lib/ai";
import { getAuthUserId } from "@/lib/auth";
const TAG_TIMEOUT_MS = 60_000;
@@ -26,13 +27,13 @@ function applyTags(ideaId: string, content: string) {
},
});
})
.catch(() => {});
.catch((e) => { console.error("updateIdeaTags: background tag update failed:", e); });
}
export const POST = apiHandler(async (req) => {
const { roomId, userId, content } = await req.json();
const userId = await getAuthUserId(req);
const { roomId, content } = await req.json();
requireUserId(userId);
requireString(roomId, "roomId");
const trimmedContent = validateIdeaContent(content);
@@ -48,7 +49,7 @@ export const POST = apiHandler(async (req) => {
});
export const GET = apiHandler(async (req) => {
const userId = requireUserId(req.nextUrl.searchParams.get("userId"));
const userId = await getAuthUserId(req);
const roomId = requireString(req.nextUrl.searchParams.get("roomId"), "roomId");
await requireMembership(roomId, userId);
@@ -88,9 +89,9 @@ export const GET = apiHandler(async (req) => {
});
export const PUT = apiHandler(async (req) => {
const { ideaId, userId, content } = await req.json();
const userId = await getAuthUserId(req);
const { ideaId, content } = await req.json();
requireUserId(userId);
requireString(ideaId, "ideaId");
const trimmedContent = validateIdeaContent(content);
@@ -107,9 +108,9 @@ export const PUT = apiHandler(async (req) => {
});
export const DELETE = apiHandler(async (req) => {
const { ideaId, userId } = await req.json();
const userId = await getAuthUserId(req);
const { ideaId } = await req.json();
requireUserId(userId);
requireString(ideaId, "ideaId");
const { count } = await prisma.blindBoxIdea.deleteMany({
+4 -4
View File
@@ -1,18 +1,18 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireMembership } from "@/lib/blindbox";
import { apiHandler, ApiError, requireUserId } from "@/lib/api";
import { apiHandler, ApiError } from "@/lib/api";
import { getAuthUserId } from "@/lib/auth";
import { suggestIdeas } from "@/lib/ai";
export const GET = apiHandler(async (req) => {
const userId = await getAuthUserId(req);
const { searchParams } = new URL(req.url);
const roomId = searchParams.get("roomId");
const userId = searchParams.get("userId");
requireUserId(userId);
if (!roomId) throw new ApiError("roomId 不能为空");
await requireMembership(roomId, userId!);
await requireMembership(roomId, userId);
const recentIdeas = await prisma.blindBoxIdea.findMany({
where: { roomId, status: "in_pool" },
+2 -2
View File
@@ -59,8 +59,8 @@ export async function GET(
try {
const status = await buildRoomStatus(id);
if (status && alive) send(status);
} catch {
/* ignore transient read errors */
} catch (e) {
console.error("SSE: transient read error:", e);
}
});
+5 -5
View File
@@ -1,20 +1,20 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { apiHandler, requireUserId } from "@/lib/api";
import { apiHandler } from "@/lib/api";
import { getAuthUserId } from "@/lib/auth";
export const GET = apiHandler(async (req) => {
const userId = req.nextUrl.searchParams.get("userId");
requireUserId(userId);
const userId = await getAuthUserId(req);
const [decisions, contracts] = await Promise.all([
prisma.decision.findMany({
where: { userId: userId! },
where: { userId },
orderBy: { createdAt: "desc" },
take: 50,
}),
prisma.weekendPlan.findMany({
where: {
userId: userId!,
userId,
status: { in: ["completed", "expired"] },
},
orderBy: { createdAt: "desc" },
+7 -7
View File
@@ -1,11 +1,11 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api";
import { apiHandler, ApiError, requireUser } from "@/lib/api";
import { getAuthUserId } from "@/lib/auth";
export const GET = apiHandler(async (req) => {
const userId = req.nextUrl.searchParams.get("userId");
if (!userId) return NextResponse.json([]);
const userId = await getAuthUserId(req);
const favorites = await prisma.favorite.findMany({
where: { userId },
@@ -23,9 +23,9 @@ export const GET = apiHandler(async (req) => {
});
export const POST = apiHandler(async (req) => {
const { userId, restaurant } = await req.json();
const userId = await getAuthUserId(req);
const { restaurant } = await req.json();
requireUserId(userId);
if (!restaurant?.id || typeof restaurant.id !== "string") {
throw new ApiError("缺少必要字段");
}
@@ -53,9 +53,9 @@ export const POST = apiHandler(async (req) => {
});
export const DELETE = apiHandler(async (req) => {
const { userId, favoriteId } = await req.json();
const userId = await getAuthUserId(req);
const { favoriteId } = await req.json();
requireUserId(userId);
if (!favoriteId) throw new ApiError("缺少必要字段");
const fav = await prisma.favorite.findUnique({ where: { id: favoriteId } });
+5 -6
View File
@@ -1,12 +1,12 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api";
import { apiHandler, ApiError, requireUser } from "@/lib/api";
import { getAuthUserId } from "@/lib/auth";
const MAX_HISTORY = 50;
export const GET = apiHandler(async (req) => {
const userId = req.nextUrl.searchParams.get("userId");
if (!userId) return NextResponse.json([]);
const userId = await getAuthUserId(req);
const decisions = await prisma.decision.findMany({
where: { userId },
@@ -28,10 +28,9 @@ export const GET = apiHandler(async (req) => {
});
export const POST = apiHandler(async (req) => {
const { userId, roomId, restaurant, matchType, participants } =
await req.json();
const userId = await getAuthUserId(req);
const { roomId, restaurant, matchType, participants } = await req.json();
requireUserId(userId);
if (!roomId || !restaurant || !matchType) {
throw new ApiError("缺少必要字段");
}
+20 -9
View File
@@ -2,17 +2,29 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import bcrypt from "bcryptjs";
import { apiHandler, ApiError, requireUserId, requireUser } from "@/lib/api";
import { apiHandler, ApiError, requireUser } from "@/lib/api";
import { validateUsername, validatePassword, validateEmail } from "@/lib/validation";
import { getAuthUserId } from "@/lib/auth";
export const GET = apiHandler(async (req) => {
const userId = req.nextUrl.searchParams.get("id");
if (!userId) return NextResponse.json(null);
// GET still allows querying by id param (for public profile viewing)
// but sensitive fields are only shown to the owner
const queryId = req.nextUrl.searchParams.get("id");
if (!queryId) return NextResponse.json(null);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: queryId } });
if (!user) return NextResponse.json(null);
const decisionCount = await prisma.decision.count({ where: { userId } });
const decisionCount = await prisma.decision.count({ where: { userId: queryId } });
// Check if the requester is the profile owner
let isOwner = false;
try {
const authId = await getAuthUserId(req);
isOwner = authId === queryId;
} catch {
// Not logged in — show public profile only
}
let preferences = {};
try { preferences = JSON.parse(user.preferences); } catch { /* fallback */ }
@@ -21,18 +33,17 @@ export const GET = apiHandler(async (req) => {
id: user.id,
username: user.username,
avatar: user.avatar,
email: user.email,
preferences,
email: isOwner ? user.email : undefined,
preferences: isOwner ? preferences : undefined,
createdAt: user.createdAt.toISOString(),
decisionCount,
});
});
export const PUT = apiHandler(async (req) => {
const userId = await getAuthUserId(req);
const body = await req.json();
const { userId } = body;
requireUserId(userId);
const existing = await requireUser(userId);
const updateData: Record<string, unknown> = {};
+7 -7
View File
@@ -184,7 +184,7 @@ export default function BlindboxRoomPage() {
setMyIdeas(data.myIdeas ?? []);
setDrawnHistory(data.drawn ?? []);
}
} catch { /* ignore */ }
} catch (e) { console.error("fetchIdeas failed:", e); }
}, [room]);
const fetchSuggestions = useCallback(async () => {
@@ -202,7 +202,7 @@ export default function BlindboxRoomPage() {
return;
}
}
} catch { /* ignore */ }
} catch (e) { console.error("fetchSuggestions failed:", e); }
setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4));
setSuggestionsSource("static");
setSuggestionsLoading(false);
@@ -230,7 +230,7 @@ export default function BlindboxRoomPage() {
endTime: data.plan.endTime ?? null,
});
}
} catch { /* ignore */ }
} catch (e) { console.error("fetchAcceptedPlan failed:", e); }
}, [room]);
useEffect(() => {
@@ -251,7 +251,7 @@ export default function BlindboxRoomPage() {
if (!res.ok) return;
const data = await res.json();
if (data.pending?.length) setPendingContracts(data.pending);
} catch { /* ignore */ }
} catch (e) { console.error("fetchPendingContracts failed:", e); }
})();
}, [isMember]);
@@ -281,7 +281,7 @@ export default function BlindboxRoomPage() {
fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`)
.then((r) => r.json())
.then((d) => { if (d.pending?.length) setPendingContracts(d.pending); })
.catch(() => {});
.catch((e) => { console.error("refreshPendingContracts failed:", e); });
}
}, ms);
@@ -314,7 +314,7 @@ export default function BlindboxRoomPage() {
setIsMember(true);
fetchRoom();
}
} catch { /* ignore */ }
} catch (e) { console.error("handleJoinRoom failed:", e); }
finally { setJoiningRoom(false); }
};
@@ -1151,7 +1151,7 @@ export default function BlindboxRoomPage() {
days: planDays,
endTime: data.endTime ?? null,
});
} catch { /* best-effort */ }
} catch (e) { console.error("acceptPlan failed:", e); }
}
toast.show("契约已接受!");
timersRef.current.push(setTimeout(() => {
+4 -2
View File
@@ -80,7 +80,8 @@ export default function PanicPage() {
const data: LocationSuggestion[] = await res.json();
setSuggestions(Array.isArray(data) ? data : []);
setShowSuggestions(Array.isArray(data) && data.length > 0);
} catch {
} catch (e) {
console.error("PanicPage: fetchSuggestions failed:", e);
setSuggestions([]);
} finally {
setFetchingSuggestions(false);
@@ -178,7 +179,8 @@ export default function PanicPage() {
try {
await joinRoom(roomCode, getUserId());
router.push(`/room/${roomCode}`);
} catch {
} catch (e) {
console.error("PanicPage: handleJoin failed:", e);
setError("房间不存在,请检查房间号");
setLoading(false);
}
+5 -4
View File
@@ -80,7 +80,8 @@ export default function ProfilePage() {
router.push("/");
}
})
.catch(() => {
.catch((e) => {
console.error("ProfilePage: fetch user failed:", e);
setProfile({ ...cached });
})
.finally(() => setLoading(false));
@@ -93,7 +94,7 @@ export default function ProfilePage() {
fetch(`/api/user/favorite?userId=${userId}`)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data) => setFavorites(Array.isArray(data) ? data : []))
.catch(() => {})
.catch((e) => { console.error("ProfilePage: fetch favorites failed:", e); })
.finally(() => setFavLoading(false));
}, [userId]);
@@ -226,8 +227,8 @@ export default function ProfilePage() {
}
};
const handleLogout = () => {
logout();
const handleLogout = async () => {
await logout();
router.push("/");
};
+1 -1
View File
@@ -128,7 +128,7 @@ function PoiSearchField({ poi, address, onSelect, location }: PoiSearchFieldProp
setSuggestions(data);
setOpen(data.length > 0);
}
} catch { /* ignore */ }
} catch (e) { console.error("PoiSearchField fetch failed:", e); }
finally { setLoading(false); }
}, 400);
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
+1 -1
View File
@@ -38,7 +38,7 @@ export default function ContractCompletionModal({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planId: contract.id, userId, action }),
});
} catch { /* best-effort */ }
} catch (e) { console.error("ContractCompletionModal: submit failed:", e); }
setLoading(false);
if (current < contracts.length - 1) {
+3 -3
View File
@@ -107,7 +107,7 @@ export default function MatchResult({
matchType,
participants: userCount,
}),
}).catch(() => {});
}).catch((e) => { console.error("MatchResult: save history failed:", e); });
}, [registered, userId, roomId, restaurant, matchType, userCount]);
const handleOpenShareCard = useCallback(() => {
@@ -136,8 +136,8 @@ export default function MatchResult({
setFavorited(true);
toast.show("已收藏");
}
} catch {
/* ignore */
} catch (e) {
console.error("MatchResult: handleFavorite failed:", e);
}
setFavLoading(false);
}, [registered, userId, restaurant, favorited, favLoading, toast]);
+1 -1
View File
@@ -146,7 +146,7 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
body: JSON.stringify({ userId: getUserId(), restaurant }),
});
if (res.ok) setFavorited(true);
} catch {}
} catch (e) { console.error("RestaurantCard: handleFavorite failed:", e); }
}, [restaurant, favorited]);
const openLink = useCallback(
(url: string) => (e: React.MouseEvent | React.TouchEvent) => {
+4 -4
View File
@@ -227,8 +227,8 @@ export default function SwipeDeck({
if (data.match != null) {
setLocalMatchId(data.match);
}
} catch {
// Polling will catch match state
} catch (e) {
console.error("SwipeDeck: sendSwipe failed:", e);
}
};
@@ -273,8 +273,8 @@ export default function SwipeDeck({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, restaurantId: lastRid }),
});
} catch {
// Best-effort
} catch (e) {
console.error("SwipeDeck: handleUndo failed:", e);
}
setSwipeHistory((h) => h.slice(0, -1));
+2 -1
View File
@@ -37,7 +37,8 @@ async function reverseGeocode(lat: number, lng: number): Promise<string | null>
const res = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
const data = await res.json();
return data.name || data.formatted || null;
} catch {
} catch (e) {
console.error("reverseGeocode failed:", e);
return null;
}
}
+2 -2
View File
@@ -36,8 +36,8 @@ export function useRoomPolling(roomId: string | undefined) {
if (parsed.roomId) {
mutate(parsed, { revalidate: false });
}
} catch {
/* malformed message */
} catch (e) {
console.error("useRoomPolling: malformed SSE message:", e);
}
};
+12 -6
View File
@@ -132,7 +132,8 @@ export async function tagIdea(content: string): Promise<IdeaTags | null> {
searchQuery: parsed.searchQuery,
searchType: parsed.searchType,
};
} catch {
} catch (e) {
console.error("tagIdea failed:", e);
return null;
}
}
@@ -170,7 +171,8 @@ export async function suggestIdeas(existingIdeas: string[]): Promise<string[]> {
return parsed.suggestions
.filter((s: unknown) => typeof s === "string" && s.length > 0)
.slice(0, 4);
} catch {
} catch (e) {
console.error("suggestIdeas failed:", e);
return [];
}
}
@@ -236,7 +238,8 @@ ${Object.entries(ctx.candidates)
})),
summary: String(parsed.summary ?? ""),
};
} catch {
} catch (e) {
console.error("generateSchedule failed:", e);
return null;
}
}
@@ -284,7 +287,8 @@ export async function refinePlan(
})) return null;
return result as import("@/types").WeekendPlanData[];
} catch {
} catch (e) {
console.error("refinePlan failed:", e);
return null;
}
}
@@ -337,7 +341,8 @@ export async function suggestAlternativeItems(
typeof (a as Record<string, unknown>).searchQuery === "string",
)
.slice(0, 3) as Array<{ activity: string; searchQuery: string; reason: string }>;
} catch {
} catch (e) {
console.error("suggestAlternativeItems failed:", e);
return null;
}
}
@@ -437,7 +442,8 @@ export async function runAgentLoop(
let args: Record<string, unknown>;
try {
args = JSON.parse(toolCall.function.arguments);
} catch {
} catch (e) {
console.error("runAgentLoop: failed to parse tool arguments:", e);
args = {};
}
+5 -1
View File
@@ -12,7 +12,11 @@ export class ApiError extends Error {
}
}
/** Validates that value is a non-empty string; throws 401 otherwise. */
/**
* Validates that value is a non-empty string; throws 401 otherwise.
* Used for room routes where anonymous users pass userId in body.
* For registered-user routes, prefer getAuthUserId() from lib/auth.
*/
export function requireUserId(value: unknown): string {
if (!value || typeof value !== "string") {
throw new ApiError("请先登录", 401);
+80
View File
@@ -0,0 +1,80 @@
import { SignJWT, jwtVerify } from "jose";
import { NextRequest, NextResponse } from "next/server";
import { ApiError } from "@/lib/api";
const COOKIE_NAME = "nw_token";
const TOKEN_MAX_AGE = 7 * 24 * 60 * 60; // 7 days in seconds
function getSecret() {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("JWT_SECRET environment variable is not set");
}
return new TextEncoder().encode(secret);
}
export async function signToken(userId: string): Promise<string> {
return new SignJWT({ sub: userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${TOKEN_MAX_AGE}s`)
.sign(getSecret());
}
export async function verifyToken(token: string): Promise<string> {
const { payload } = await jwtVerify(token, getSecret());
if (!payload.sub) throw new Error("Invalid token payload");
return payload.sub;
}
export function setAuthCookie(res: NextResponse, token: string): NextResponse {
res.cookies.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: TOKEN_MAX_AGE,
});
return res;
}
export function clearAuthCookie(res: NextResponse): NextResponse {
res.cookies.set(COOKIE_NAME, "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 0,
});
return res;
}
/**
* Extracts authenticated userId from JWT cookie.
* Throws 401 if no valid token is present.
*/
export async function getAuthUserId(req: NextRequest): Promise<string> {
const token = req.cookies.get(COOKIE_NAME)?.value;
if (!token) {
throw new ApiError("请先登录", 401);
}
try {
return await verifyToken(token);
} catch {
throw new ApiError("登录已过期,请重新登录", 401);
}
}
/**
* Optionally extracts userId from JWT cookie.
* Returns null if no valid token (does not throw).
*/
export async function getOptionalAuthUserId(req: NextRequest): Promise<string | null> {
const token = req.cookies.get(COOKIE_NAME)?.value;
if (!token) return null;
try {
return await verifyToken(token);
} catch {
return null;
}
}
+12 -6
View File
@@ -319,7 +319,8 @@ function buildAgentTools(
try {
const pois = await searchPois(query, searchType, lat, lng);
return JSON.stringify(pois);
} catch {
} catch (e) {
console.error("searchPoiTool failed:", e);
return JSON.stringify([]);
}
},
@@ -369,7 +370,8 @@ function buildAgentTools(
const distanceKm = Math.round(Number(data.route.distance) / 100) / 10;
const { description, mode } = parseTransitSegments(transit.segments ?? []);
return JSON.stringify({ durationMin, distanceKm, description, mode });
} catch {
} catch (e) {
console.error("getTravelTimeTool failed:", e);
return JSON.stringify({ error: "路线查询失败" });
}
},
@@ -535,7 +537,8 @@ async function runLegacyPlanGeneration(
try {
const pois = await searchPois(idea.searchQuery, idea.searchType, room.lat, room.lng);
return { query: idea.searchQuery, pois };
} catch {
} catch (e) {
console.error(`searchPois failed for "${idea.searchQuery}":`, e);
return { query: idea.searchQuery, pois: [] };
}
}),
@@ -560,7 +563,8 @@ async function runLegacyPlanGeneration(
try {
const pois = await searchPois(idea.searchQuery, idea.searchType, anchorLat, anchorLng);
return { query: idea.searchQuery, pois };
} catch {
} catch (e) {
console.error(`searchPois (category) failed for "${idea.searchQuery}":`, e);
return { query: idea.searchQuery, pois: [] };
}
}),
@@ -643,7 +647,8 @@ async function queryTransit(
const transit = data.route.transits[0];
const { description } = parseTransitSegments(transit.segments ?? []);
return { durationMin: Math.ceil(Number(transit.duration) / 60), description };
} catch {
} catch (e) {
console.error("queryTransit failed:", e);
return null;
}
}
@@ -770,7 +775,8 @@ export async function runPlanGeneration(
onProgress,
);
days = agentResult.days;
} catch {
} catch (e) {
console.error("runAgentPlanGeneration failed, falling back to legacy:", e);
onProgress?.("使用备用方案规划...");
const legacyResult = await runLegacyPlanGeneration(
{ lat: room.lat, lng: room.lng },
+2 -1
View File
@@ -15,7 +15,8 @@ export async function loadImageAsDataUrl(src: string): Promise<string | null> {
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0);
return canvas.toDataURL("image/jpeg", 0.85);
} catch {
} catch (e) {
console.error("proxyToDataUrl failed:", e);
return null;
}
}
+39 -14
View File
@@ -102,32 +102,57 @@ export async function getRoomData(
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) return null;
if (room.expiresAt < new Date()) {
prisma.room.delete({ where: { id: roomId } }).catch(() => {});
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));
}
/**
* Atomic read-modify-write within a Prisma transaction.
* Prevents race conditions when multiple users swipe concurrently.
* 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 prisma.$transaction(async (tx) => {
const room = await tx.room.findUnique({ where: { id: roomId } });
if (!room) return 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);
const data = normalize(JSON.parse(room.data));
const updated = updater(data);
await tx.room.update({
where: { id: roomId },
data: { data: JSON.stringify(updated) },
});
await tx.room.update({
where: { id: roomId },
data: { data: JSON.stringify(updated) },
});
return updated;
});
return updated;
}),
);
}
+7 -1
View File
@@ -55,8 +55,14 @@ export function setCachedPreferences(prefs: UserPreferences): void {
localStorage.setItem("nowhatever_preferences", JSON.stringify(prefs));
}
export function logout(): void {
export async function logout(): Promise<void> {
if (typeof window === "undefined") return;
// Clear server-side auth cookie
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch {
// Best-effort: cookie will expire anyway
}
localStorage.removeItem(PROFILE_KEY);
localStorage.removeItem("nowhatever_preferences");
const newId = crypto.randomUUID();