diff --git a/frontend/app/api/payments/wallets/challenge/route.ts b/frontend/app/api/payments/wallets/challenge/route.ts index 6db5ec76..8bd8a699 100644 --- a/frontend/app/api/payments/wallets/challenge/route.ts +++ b/frontend/app/api/payments/wallets/challenge/route.ts @@ -10,6 +10,42 @@ import { } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; +const BACKEND_RETRY_DELAY_MS = 250; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchWalletChallengeWithRetry( + url: string, + init: RequestInit, + attempts = 2, +) { + let lastError: unknown; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await fetch(url, init); + } catch (error) { + lastError = error; + if (attempt >= attempts) break; + await sleep(BACKEND_RETRY_DELAY_MS * attempt); + } + } + throw lastError; +} + +function logWalletChallengeProxyError( + error: unknown, + authDebug: Record, +) { + const source = error as { name?: unknown; message?: unknown; cause?: unknown }; + console.error("[payment-wallet-challenge-proxy-exception]", { + error_name: String(source?.name || "Error"), + error_message: String(source?.message || error || "unknown"), + error_cause: source?.cause ? String(source.cause) : "", + ...authDebug, + }); +} export async function POST(req: NextRequest) { if (!API_BASE) { @@ -18,31 +54,45 @@ export async function POST(req: NextRequest) { { status: 500 }, ); } + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { error: "Invalid wallet challenge request" }, + { status: 400 }, + ); + } + let authDebug: Record = {}; try { - const body = await req.json(); const auth = await buildBackendRequestHeaders(req); const authError = requireBackendPaymentAuth(auth); if (authError) return authError; const proxiedHeaders = new Headers(auth.headers); proxiedHeaders.set("Content-Type", "application/json"); - const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, { - method: "POST", - headers: proxiedHeaders, - body: JSON.stringify(body ?? {}), - cache: "no-store", - }); + authDebug = { + incoming_has_authorization: Boolean( + String(req.headers.get("authorization") || "").trim(), + ), + has_authorization: proxiedHeaders.has("authorization"), + has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"), + has_forwarded_user_id: proxiedHeaders.has("x-polyweather-auth-user-id"), + has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"), + }; + const res = await fetchWalletChallengeWithRetry( + `${API_BASE}/api/payments/wallets/challenge`, + { + method: "POST", + headers: proxiedHeaders, + body: JSON.stringify(body ?? {}), + cache: "no-store", + }, + ); if (!res.ok) { const raw = await res.text(); const response = buildUpstreamErrorResponse(res.status, raw, { detailLimit: 350, - extraDebug: { - has_authorization: proxiedHeaders.has("authorization"), - has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"), - has_forwarded_user_id: proxiedHeaders.has( - "x-polyweather-auth-user-id", - ), - has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"), - }, + extraDebug: authDebug, }); return applyAuthResponseCookies(response, auth.response); } @@ -50,8 +100,12 @@ export async function POST(req: NextRequest) { const response = NextResponse.json(data); return applyAuthResponseCookies(response, auth.response); } catch (error) { + logWalletChallengeProxyError(error, authDebug); return buildProxyExceptionResponse(error, { - publicMessage: "Failed to create wallet challenge", + status: 502, + publicMessage: + "Wallet challenge service is temporarily unavailable. Please retry.", + extra: { retryable: true }, }); } } diff --git a/frontend/components/account/__tests__/paymentSecurity.test.ts b/frontend/components/account/__tests__/paymentSecurity.test.ts index 1a1f42a4..10761acf 100644 --- a/frontend/components/account/__tests__/paymentSecurity.test.ts +++ b/frontend/components/account/__tests__/paymentSecurity.test.ts @@ -44,6 +44,10 @@ export function runTests() { ); const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8"); + const walletBindSource = fs.readFileSync( + path.join(projectRoot, "components", "account", "useWalletBind.ts"), + "utf8", + ); const hookPath = path.join( projectRoot, "components", @@ -221,4 +225,25 @@ export function runTests() { `${route} must allow bearer-backed payment mutations while still rejecting requests with no auth context`, ); } + + const walletChallengeRouteSource = fs.readFileSync( + path.join(projectRoot, "app/api/payments/wallets/challenge/route.ts"), + "utf8", + ); + assert( + walletChallengeRouteSource.includes("fetchWalletChallengeWithRetry"), + "wallet challenge proxy must retry a transient backend connection failure before blocking payment", + ); + assert( + walletChallengeRouteSource.includes("Invalid wallet challenge request"), + "wallet challenge proxy must return a client error for malformed JSON instead of a generic payment failure", + ); + assert( + walletChallengeRouteSource.includes("retryable: true"), + "wallet challenge proxy exception response must mark transient failures as retryable", + ); + assert( + walletBindSource.includes("readPaymentApiErrorMessage"), + "wallet binding errors must show the API error message instead of raw JSON", + ); } diff --git a/frontend/components/account/payment-utils.ts b/frontend/components/account/payment-utils.ts index da90ddd0..cc02dbc1 100644 --- a/frontend/components/account/payment-utils.ts +++ b/frontend/components/account/payment-utils.ts @@ -68,6 +68,29 @@ export type NormalizedPaymentError = { userRejected: boolean; }; +export async function readPaymentApiErrorMessage( + response: Response, + fallback = "Request failed", + limit = 300, +) { + const raw = (await response.text()).slice(0, limit); + if (!raw) return fallback; + try { + const parsed = JSON.parse(raw) as { + error?: unknown; + detail?: unknown; + message?: unknown; + }; + const message = [parsed.error, parsed.detail, parsed.message].find( + (item) => typeof item === "string" && item.trim(), + ); + if (typeof message === "string") return message.trim(); + } catch { + // Fall back to the raw response body below. + } + return raw; +} + export function normalizePaymentError(error: unknown): NormalizedPaymentError { const source = error as any; const code = Number( diff --git a/frontend/components/account/useWalletBind.ts b/frontend/components/account/useWalletBind.ts index 783dcfa3..502feb29 100644 --- a/frontend/components/account/useWalletBind.ts +++ b/frontend/components/account/useWalletBind.ts @@ -16,7 +16,11 @@ import { WALLET_TRANSACTION_REQUEST_TIMEOUT_MS, } from "./constants"; import { shortAddress } from "./formatters"; -import { normalizePaymentError, requestWalletWithTimeout } from "./payment-utils"; +import { + normalizePaymentError, + readPaymentApiErrorMessage, + requestWalletWithTimeout, +} from "./payment-utils"; import { eip6963Providers, getEvmProvider, @@ -300,8 +304,8 @@ export function useWalletBind(params: UseWalletBindParams) { method: "POST", headers: authHeaders, body: JSON.stringify({ address }), }); if (!challengeRes.ok) { - const raw = (await challengeRes.text()).slice(0, 300); - throw new Error(copy.challengeFailed.replace("{raw}", raw)); + const message = await readPaymentApiErrorMessage(challengeRes); + throw new Error(copy.challengeFailed.replace("{raw}", message)); } const challengeJson = (await challengeRes.json()) as { nonce?: string; message?: string }; @@ -314,8 +318,8 @@ export function useWalletBind(params: UseWalletBindParams) { method: "POST", headers: authHeaders, body: JSON.stringify({ address, nonce, signature }), }); if (!verifyRes.ok) { - const raw = (await verifyRes.text()).slice(0, 300); - throw new Error(copy.verifyFailedRaw.replace("{raw}", raw)); + const message = await readPaymentApiErrorMessage(verifyRes); + throw new Error(copy.verifyFailedRaw.replace("{raw}", message)); } setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`);