From 22610f0b595bc1088ab3245bd17374c70b4da018 Mon Sep 17 00:00:00 2001 From: kurihada Date: Tue, 3 Mar 2026 13:17:42 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=9B=B2=E7=9B=92=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20API=20=E8=B0=83=E7=94=A8=E5=B1=82=E5=B9=B6=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_AUDIT_2026-03-03.md | 15 +++--- src/app/blindbox/page.tsx | 22 +++------ src/hooks/useBlindboxDraw.ts | 13 ++---- src/hooks/useBlindboxIdeas.ts | 81 ++++++++++++++++---------------- src/hooks/useBlindboxPlan.ts | 87 ++++++++++++++++++++--------------- src/hooks/useBlindboxRoom.ts | 31 +++++-------- src/lib/fetcher.ts | 83 +++++++++++++++++++++++++++++++++ 7 files changed, 204 insertions(+), 128 deletions(-) diff --git a/PROJECT_AUDIT_2026-03-03.md b/PROJECT_AUDIT_2026-03-03.md index 64b8ef6..d6a6e9d 100644 --- a/PROJECT_AUDIT_2026-03-03.md +++ b/PROJECT_AUDIT_2026-03-03.md @@ -229,12 +229,15 @@ - 证据: - `rg` 检索显示盲盒前端链路已无 `?userId=` 或 `userId: profile.id` 传参。 -### R4 统一 API 调用层(减少重复 fetch + 错误处理分散) -- 现状: - - 客户端很多模块各自拼接 URL、手写错误分支。 -- 建议: - - 为业务 API 建立 typed client(含统一重试、错误映射、鉴权处理); - - 与 SWR key 规范化一起推进。 +### R4 统一 API 调用层(减少重复 fetch + 错误处理分散)【已完成】 +- 修复状态:✅ 已完成(2026-03-03) +- 修复内容: + - 在 `src/lib/fetcher.ts` 增加统一请求入口 `requestJson` 与 `ApiRequestError`,统一 JSON 序列化、响应解析与错误映射; + - 盲盒核心前端链路已迁移到统一调用层(`blindbox/page`、`useBlindboxIdeas`、`useBlindboxRoom`、`useBlindboxDraw`、`useBlindboxPlan`); + - 降低重复 `fetch + res.ok + res.json` 模板代码,错误处理集中化。 +- 证据: + - 以上模块中的请求分支已改为 `requestJson(...)` 调用; + - 相关盲盒 API/UI 回归测试通过,`npx tsc --noEmit` 通过。 --- diff --git a/src/app/blindbox/page.tsx b/src/app/blindbox/page.tsx index 6918eaa..9d68412 100644 --- a/src/app/blindbox/page.tsx +++ b/src/app/blindbox/page.tsx @@ -18,6 +18,7 @@ import { X, } from "lucide-react"; import { getCachedProfile, isRegistered } from "@/lib/userId"; +import { requestJson } from "@/lib/fetcher"; import AuthModal from "@/components/AuthModal"; import Button from "@/components/Button"; import Input from "@/components/Input"; @@ -230,13 +231,10 @@ export default function BlindboxLobbyPage() { setCreating(true); setError(""); try { - const res = await fetch("/api/blindbox/room", { + const data = await requestJson<{ code: string }, { name?: string }>("/api/blindbox/room", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: name || undefined }), + body: { name: name || undefined }, }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error); router.push(`/blindbox/${data.code}`); } catch (e) { setError(e instanceof Error ? e.message : "创建失败"); @@ -251,13 +249,10 @@ export default function BlindboxLobbyPage() { setJoining(true); setError(""); try { - const res = await fetch("/api/blindbox/room/join", { + const data = await requestJson<{ code: string }, { code: string }>("/api/blindbox/room/join", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code }), + body: { code }, }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error); router.push(`/blindbox/${data.code}`); } catch (e) { setError(e instanceof Error ? e.message : "加入失败"); @@ -270,14 +265,9 @@ export default function BlindboxLobbyPage() { if (deletingId || !profile) return; setDeletingId(room.id); try { - const res = await fetch(`/api/blindbox/room/${room.code}`, { + await requestJson<{ action: "deleted" | "left" }>(`/api/blindbox/room/${room.code}`, { method: "DELETE", - headers: { "Content-Type": "application/json" }, }); - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "操作失败"); - } mutateRooms((prev) => prev ? { rooms: prev.rooms.filter((r) => r.id !== room.id) } : prev, false); setConfirmDeleteId(null); toast.show(room.creatorId === profile.id ? "房间已删除" : "已退出房间"); diff --git a/src/hooks/useBlindboxDraw.ts b/src/hooks/useBlindboxDraw.ts index a45c29e..a4281e8 100644 --- a/src/hooks/useBlindboxDraw.ts +++ b/src/hooks/useBlindboxDraw.ts @@ -3,6 +3,7 @@ import { useState, useRef, useCallback, useEffect } from "react"; import { useAnimation } from "framer-motion"; import confetti from "canvas-confetti"; +import { requestJson } from "@/lib/fetcher"; import type { DrawnIdea } from "@/components/BlindboxDrawnHistory"; import type { RoomInfo } from "@/hooks/useBlindboxRoom"; import type { UserProfile } from "@/types"; @@ -78,18 +79,10 @@ export function useBlindboxDraw( }); try { - const res = await fetch("/api/blindbox/draw", { + const idea = await requestJson("/api/blindbox/draw", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ roomId: room.id }), + body: { roomId: room.id }, }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "抽取失败"); - } - - const idea = await res.json(); setRevealedIdea(idea); setPhase("reveal"); setPoolCount((c) => Math.max(0, c - 1)); diff --git a/src/hooks/useBlindboxIdeas.ts b/src/hooks/useBlindboxIdeas.ts index 7c521df..27a9809 100644 --- a/src/hooks/useBlindboxIdeas.ts +++ b/src/hooks/useBlindboxIdeas.ts @@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { useAnimation } from "framer-motion"; import { useToast } from "@/hooks/useToast"; +import { requestJson } from "@/lib/fetcher"; import type { MyIdea } from "@/components/BlindboxMyIdeas"; import type { DrawnIdea } from "@/components/BlindboxDrawnHistory"; import type { RoomInfo } from "@/hooks/useBlindboxRoom"; @@ -34,6 +35,30 @@ function pickRandom(arr: T[], n: number): T[] { return shuffled.slice(0, n); } +interface IdeasResponse { + poolCount?: number; + myIdeas?: MyIdea[]; + drawn?: DrawnIdea[]; +} + +interface SuggestionsResponse { + suggestions?: string[]; +} + +interface IdeaMutationResponse { + id: string; + tags?: { + category: string; + timeSlot: string; + estimatedMinutes: number; + costLevel: string; + intensity: string; + needsBooking: boolean; + searchQuery: string; + searchType: string; + }; +} + export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | null) { const toast = useToast(); const boxControls = useAnimation(); @@ -62,13 +87,10 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n const fetchIdeas = useCallback(async () => { if (!room || !profile) return; try { - const res = await fetch(`/api/blindbox?roomId=${room.id}`); - if (res.ok) { - const data = await res.json(); - setPoolCount(data.poolCount ?? 0); - setMyIdeas(data.myIdeas ?? []); - setDrawnHistory(data.drawn ?? []); - } + const data = await requestJson(`/api/blindbox?roomId=${room.id}`); + setPoolCount(data.poolCount ?? 0); + setMyIdeas(data.myIdeas ?? []); + setDrawnHistory(data.drawn ?? []); } catch (e) { console.error("fetchIdeas failed:", e); } }, [room, profile]); @@ -76,15 +98,13 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n if (!room || !profile) return; setSuggestionsLoading(true); try { - const res = await fetch(`/api/blindbox/suggest?roomId=${room.id}`); - if (res.ok) { - const data = await res.json(); - if (data.suggestions?.length > 0) { - setSuggestions(data.suggestions); - setSuggestionsSource("ai"); - setSuggestionsLoading(false); - return; - } + const data = await requestJson(`/api/blindbox/suggest?roomId=${room.id}`); + const suggested = data.suggestions ?? []; + if (suggested.length > 0) { + setSuggestions(suggested); + setSuggestionsSource("ai"); + setSuggestionsLoading(false); + return; } } catch (e) { console.error("fetchSuggestions failed:", e); } setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4)); @@ -106,16 +126,10 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n setSubmitting(true); setError(""); try { - const res = await fetch("/api/blindbox", { + const data = await requestJson("/api/blindbox", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ roomId: room.id, content: text }), + body: { roomId: room.id, content: text }, }); - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "提交失败"); - } - const data = await res.json(); setInput(""); setPoolCount((c) => c + 1); setMyIdeas((prev) => [{ @@ -153,16 +167,10 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n const trimmed = newContent.trim(); if (!trimmed || trimmed.length > 200) return; try { - const res = await fetch("/api/blindbox", { + const data = await requestJson("/api/blindbox", { method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ideaId, content: trimmed }), + body: { ideaId, content: trimmed }, }); - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "编辑失败"); - } - const data = await res.json(); setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? { ...i, content: trimmed, @@ -185,15 +193,10 @@ export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | n const handleDeleteIdea = useCallback(async (ideaId: string) => { if (!profile) return; try { - const res = await fetch("/api/blindbox", { + await requestJson<{ deleted: boolean }, { ideaId: string }>("/api/blindbox", { method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ideaId }), + body: { ideaId }, }); - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "删除失败"); - } setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId)); setPoolCount((c) => Math.max(0, c - 1)); } catch (e) { diff --git a/src/hooks/useBlindboxPlan.ts b/src/hooks/useBlindboxPlan.ts index 5c4545d..138a93f 100644 --- a/src/hooks/useBlindboxPlan.ts +++ b/src/hooks/useBlindboxPlan.ts @@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect } from "react"; import { useToast } from "@/hooks/useToast"; +import { requestJson } from "@/lib/fetcher"; import type { RoomInfo } from "@/hooks/useBlindboxRoom"; import type { WeekendPlanData, UserProfile } from "@/types"; import type { PendingContract } from "@/components/ContractCompletionModal"; @@ -15,6 +16,28 @@ const PLAN_STATUS_STEPS = [ "快好了...", ]; +interface LatestPlanResponse { + plan?: { + id: string; + days: WeekendPlanData[]; + endTime?: string | null; + } | null; +} + +interface PendingPlansResponse { + pending?: PendingContract[]; +} + +interface GeneratedPlanResponse { + id: string; + days: WeekendPlanData[]; + createdAt: string; +} + +interface AcceptPlanResponse { + endTime?: string | null; +} + export function useBlindboxPlan( room: RoomInfo | null, profile: UserProfile | null, @@ -46,9 +69,7 @@ export function useBlindboxPlan( const fetchAcceptedPlan = useCallback(async () => { if (!room || !profile) return; try { - const res = await fetch(`/api/blindbox/plan?mode=latest&roomId=${room.id}`); - if (!res.ok) return; - const data = await res.json(); + const data = await requestJson(`/api/blindbox/plan?mode=latest&roomId=${room.id}`); if (data.plan) { setActiveContract({ id: data.plan.id, @@ -64,9 +85,7 @@ export function useBlindboxPlan( if (!profile) return; (async () => { try { - const res = await fetch("/api/blindbox/plan?mode=pending"); - if (!res.ok) return; - const data = await res.json(); + const data = await requestJson("/api/blindbox/plan?mode=pending"); if (data.pending?.length) setPendingContracts(data.pending); } catch (e) { console.error("fetchPendingContracts failed:", e); } })(); @@ -92,8 +111,7 @@ export function useBlindboxPlan( }); n.onclick = () => { window.focus(); n.close(); }; } - fetch("/api/blindbox/plan?mode=pending") - .then((r) => r.json()) + requestJson("/api/blindbox/plan?mode=pending") .then((d) => { if (d.pending?.length) setPendingContracts(d.pending); }) .catch((e) => { console.error("refreshPendingContracts failed:", e); }); }, ms); @@ -155,16 +173,10 @@ export function useBlindboxPlan( } } catch { try { - const res = await fetch("/api/blindbox/plan", { + const data = await requestJson("/api/blindbox/plan", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + body: payload, }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || "生成失败"); - } - const data = await res.json(); setPlanId(data.id); setPlanDays(data.days); setPlanAccepted(false); @@ -188,12 +200,13 @@ export function useBlindboxPlan( setActiveContract((prev) => prev ? { ...prev, days: newDays } : prev); } try { - const res = await fetch("/api/blindbox/plan", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ planId, action: "update_plan", days: newDays }), - }); - if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "保存失败"); + await requestJson( + "/api/blindbox/plan", + { + method: "PATCH", + body: { planId, action: "update_plan", days: newDays }, + }, + ); } catch (e) { setPlanDays(prevDays); if (planAccepted) setActiveContract((prev) => prev ? { ...prev, days: prevDays } : prev); @@ -205,13 +218,13 @@ export function useBlindboxPlan( if (!profile || !planDays.length) return; const prevDays = planDays; try { - const res = await fetch("/api/blindbox/plan/refine", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ instruction, days: planDays }), - }); - if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "AI 调整失败"); - const data = await res.json(); + const data = await requestJson<{ days: WeekendPlanData[] }, { instruction: string; days: WeekendPlanData[] }>( + "/api/blindbox/plan/refine", + { + method: "POST", + body: { instruction, days: planDays }, + }, + ); await handlePlanDaysChange(data.days); } catch (e) { setPlanDays(prevDays); @@ -226,15 +239,13 @@ export function useBlindboxPlan( } try { - const res = await fetch("/api/blindbox/plan", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ planId, action: "accept" }), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - throw new Error(data.error || "接受契约失败"); - } + const data = await requestJson( + "/api/blindbox/plan", + { + method: "PATCH", + body: { planId, action: "accept" }, + }, + ); setPlanAccepted(true); setActiveContract({ id: planId, days: planDays, endTime: data.endTime ?? null }); diff --git a/src/hooks/useBlindboxRoom.ts b/src/hooks/useBlindboxRoom.ts index 36c7b86..6351d04 100644 --- a/src/hooks/useBlindboxRoom.ts +++ b/src/hooks/useBlindboxRoom.ts @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; import { getCachedProfile, isRegistered } from "@/lib/userId"; +import { requestJson } from "@/lib/fetcher"; import { useToast } from "@/hooks/useToast"; import { useShare } from "@/hooks/useShare"; import type { UserProfile } from "@/types"; @@ -55,9 +56,7 @@ export function useBlindboxRoom(code: string) { const fetchRoom = useCallback(async () => { if (!code) return; try { - const res = await fetch(`/api/blindbox/room/${code}`); - if (!res.ok) { router.replace("/blindbox"); return; } - const data: RoomInfo = await res.json(); + const data = await requestJson(`/api/blindbox/room/${code}`); setRoom(data); const p = getCachedProfile(); setIsMember(data.members.some((m) => m.id === p?.id)); @@ -74,12 +73,12 @@ export function useBlindboxRoom(code: string) { if (joiningRoom || !profile || !room) return; setJoiningRoom(true); try { - const res = await fetch("/api/blindbox/room/join", { + await requestJson<{ id: string; code: string; name: string }>("/api/blindbox/room/join", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code }), + body: { code }, }); - if (res.ok) { setIsMember(true); fetchRoom(); } + setIsMember(true); + fetchRoom(); } catch (e) { console.error("handleJoinRoom failed:", e); } finally { setJoiningRoom(false); } }; @@ -92,16 +91,15 @@ export function useBlindboxRoom(code: string) { navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }), ); const { latitude: lat, longitude: lng } = pos.coords; - const regeoRes = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`); - const regeo = regeoRes.ok ? await regeoRes.json() : {}; + const regeo = await requestJson<{ name?: string; formatted?: string }>( + `/api/location/regeo?lat=${lat}&lng=${lng}`, + ); const cityName = regeo.name || "未知位置"; const addressLabel = regeo.formatted || cityName; - const patchRes = await fetch(`/api/blindbox/room/${room.code}`, { + await requestJson(`/api/blindbox/room/${room.code}`, { method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ city: cityName, address: addressLabel, lat, lng }), + body: { city: cityName, address: addressLabel, lat, lng }, }); - if (!patchRes.ok) throw new Error("保存位置失败"); setRoom((prev) => prev ? { ...prev, city: cityName, address: addressLabel, lat, lng } : prev); toast.show("位置已设置"); } catch { @@ -120,14 +118,9 @@ export function useBlindboxRoom(code: string) { if (leaving || !profile || !room) return; setLeaving(true); try { - const res = await fetch(`/api/blindbox/room/${room.code}`, { + await requestJson<{ action: "deleted" | "left" }>(`/api/blindbox/room/${room.code}`, { method: "DELETE", - headers: { "Content-Type": "application/json" }, }); - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "操作失败"); - } router.push("/blindbox"); router.refresh(); } catch (e) { diff --git a/src/lib/fetcher.ts b/src/lib/fetcher.ts index d2fcb9e..f511d7d 100644 --- a/src/lib/fetcher.ts +++ b/src/lib/fetcher.ts @@ -12,6 +12,89 @@ export class FetchError extends Error { } } +export class ApiRequestError extends Error { + constructor( + message: string, + public status: number, + public payload?: unknown, + ) { + super(message); + this.name = "ApiRequestError"; + } +} + +function extractErrorMessage(payload: unknown): string | null { + if ( + payload && + typeof payload === "object" && + "error" in payload && + typeof payload.error === "string" + ) { + return payload.error; + } + if (typeof payload === "string" && payload.trim()) { + return payload; + } + return null; +} + +async function parsePayload(res: Response): Promise { + if (res.status === 204) return undefined; + if (typeof res.text === "function") { + const text = await res.text(); + if (!text) return undefined; + try { + return JSON.parse(text); + } catch { + return text; + } + } + if (typeof res.json === "function") { + try { + return await res.json(); + } catch { + return undefined; + } + } + return undefined; +} + +type RequestJsonInit = Omit & { + body?: TBody; +}; + +/** + * Shared JSON requester for imperative client-side API calls. + * - Parses JSON/text payloads automatically + * - Throws ApiRequestError with normalized message on non-2xx + */ +export async function requestJson( + url: string, + init: RequestJsonInit = {}, +): Promise { + const { body, headers, ...rest } = init; + const hasBody = body !== undefined; + + const mergedHeaders = new Headers(headers); + if (hasBody && !mergedHeaders.has("Content-Type")) { + mergedHeaders.set("Content-Type", "application/json"); + } + + const res = await fetch(url, { + ...rest, + headers: mergedHeaders, + body: hasBody ? JSON.stringify(body) : undefined, + }); + + const payload = await parsePayload(res); + if (!res.ok) { + const message = extractErrorMessage(payload) ?? "请求失败"; + throw new ApiRequestError(message, res.status, payload); + } + + return payload as TResponse; +} + export async function fetcher(url: string): Promise { const res = await fetch(url); if (!res.ok) {