diff --git a/frontend/app/api/payments/intents/[intentId]/submit/route.ts b/frontend/app/api/payments/intents/[intentId]/submit/route.ts index f44956f0..1f988734 100644 --- a/frontend/app/api/payments/intents/[intentId]/submit/route.ts +++ b/frontend/app/api/payments/intents/[intentId]/submit/route.ts @@ -37,8 +37,14 @@ export async function POST( ); if (!res.ok) { const raw = await res.text(); + let detail = raw.slice(0, 350); + try { + const parsed = JSON.parse(raw); + if (parsed.detail) detail = String(parsed.detail).slice(0, 350); + } catch {} const response = buildUpstreamErrorResponse(res.status, raw, { detailLimit: 350, + error: detail || undefined, }); return applyAuthResponseCookies(response, auth.response); } diff --git a/frontend/app/api/payments/intents/[intentId]/validate/route.ts b/frontend/app/api/payments/intents/[intentId]/validate/route.ts new file mode 100644 index 00000000..7142c129 --- /dev/null +++ b/frontend/app/api/payments/intents/[intentId]/validate/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function POST( + req: NextRequest, + context: { params: Promise<{ intentId: string }> }, +) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + const { intentId } = await context.params; + try { + const body = await req.json(); + const auth = await buildBackendRequestHeaders(req); + const proxiedHeaders = new Headers(auth.headers); + proxiedHeaders.set("Content-Type", "application/json"); + const res = await fetch( + `${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/validate`, + { + method: "POST", + headers: proxiedHeaders, + body: JSON.stringify(body ?? {}), + cache: "no-store", + }, + ); + if (!res.ok) { + const raw = await res.text(); + let detail = raw.slice(0, 350); + try { + const parsed = JSON.parse(raw); + if (parsed.detail) detail = String(parsed.detail).slice(0, 350); + } catch {} + const response = buildUpstreamErrorResponse(res.status, raw, { + detailLimit: 350, + error: detail || undefined, + }); + return applyAuthResponseCookies(response, auth.response); + } + const data = await res.json(); + const response = NextResponse.json(data); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to validate payment tx", + }); + } +} diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 670983ce..36e4269b 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -945,6 +945,14 @@ export function AccountCenter() { CreatedIntent["direct_payment"] | null >(null); const [manualTxHash, setManualTxHash] = useState(""); + const [txValidation, setTxValidation] = useState<{ + loading: boolean; + checked: boolean; + valid?: boolean; + reason?: string; + detail?: string; + checks?: Record; + }>({ loading: false, checked: false }); const [paymentMethodTab, setPaymentMethodTab] = useState<"wallet" | "manual">("wallet"); const [lastPaymentStartedAt, setLastPaymentStartedAt] = useState(0); const [showSecondarySections, setShowSecondarySections] = useState(false); @@ -1520,6 +1528,67 @@ export function AccountCenter() { reconcileBusy, ]); + const handleSubmit409 = useCallback( + async (intentId: string, txHashNorm: string, raw: string) => { + const lowerRaw = raw.toLowerCase(); + // If intent was already confirmed (maybe by confirm loop), reconcile + if ( + lowerRaw.includes("已支付") || + lowerRaw.includes("already confirmed") || + lowerRaw.includes("already paid") + ) { + const ok = await reconcileLatestPayment(); + if (ok) return; + setPaymentInfo("该订单已支付,正在恢复订阅..."); + await loadSnapshot(); + await loadPaymentSnapshot(); + return; + } + // If intent expired, tell user to create a new order + if (lowerRaw.includes("expired")) { + throw new Error("支付订单已过期(30分钟有效),请重新创建订单。"); + } + // Try fetching intent status as fallback + try { + const headers = await buildAuthedHeaders(true, false); + const intentRes = await fetch(`/api/payments/intents/${intentId}`, { + headers, + cache: "no-store", + }); + if (intentRes.ok) { + const intentJson = (await intentRes.json()) as { + intent?: { status?: string; tx_hash?: string }; + }; + const status = intentJson.intent?.status; + if (status === "confirmed") { + await reconcileLatestPayment(); + const txHash = + intentJson.intent?.tx_hash || txHashNorm; + setPaymentInfo( + `支付已确认,交易: ${shortAddress(txHash)}`, + ); + setPaymentError(""); + await loadSnapshot(); + await loadPaymentSnapshot(); + return; + } + if (status === "expired") { + throw new Error("支付订单已过期(30分钟有效),请重新创建订单。"); + } + } + } catch (e) { + if (e instanceof Error && e.message !== raw) throw e; + } + throw new Error(`submit tx failed: ${raw}`); + }, + [ + buildAuthedHeaders, + loadPaymentSnapshot, + loadSnapshot, + reconcileLatestPayment, + ], + ); + useEffect(() => { if (!authIsAuthenticated) return; if (backend?.subscription_active) return; @@ -2535,6 +2604,10 @@ export function AccountCenter() { ); if (!submitRes.ok) { const raw = (await submitRes.text()).slice(0, 350); + if (submitRes.status === 409) { + await handleSubmit409(intentId, txHashNorm, raw); + return; + } throw new Error(`submit tx failed: ${raw}`); } @@ -2707,6 +2780,10 @@ export function AccountCenter() { ); if (!submitRes.ok) { const raw = (await submitRes.text()).slice(0, 350); + if (submitRes.status === 409) { + await handleSubmit409(intentId, txHashNorm, raw); + return; + } throw new Error(`submit tx failed: ${raw}`); } const confirmRes = await fetch( @@ -2738,6 +2815,7 @@ export function AccountCenter() { setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`); setManualPayment(null); setManualTxHash(""); + setTxValidation({ loading: false, checked: false }); trackAppEvent("checkout_succeeded", { entry: "account_center_manual_transfer", plan_code: selectedPlan?.plan_code || "pro_monthly", @@ -2753,6 +2831,42 @@ export function AccountCenter() { } }; + const validateTxHash = useCallback( + async (intentId: string, hash: string) => { + const hashNorm = String(hash || "").trim().toLowerCase(); + if (!hashNorm.startsWith("0x") || hashNorm.length !== 66) { + setTxValidation({ loading: false, checked: false }); + return; + } + setTxValidation({ loading: true, checked: false }); + try { + const headers = await buildAuthedHeaders(true, false); + const res = await fetch(`/api/payments/intents/${intentId}/validate`, { + method: "POST", + headers, + body: JSON.stringify({ tx_hash: hashNorm }), + }); + const json = (await res.json()) as { + valid?: boolean; + reason?: string; + detail?: string; + checks?: Record; + }; + setTxValidation({ + loading: false, + checked: true, + valid: Boolean(json.valid), + reason: json.reason, + detail: json.detail, + checks: json.checks, + }); + } catch { + setTxValidation({ loading: false, checked: false }); + } + }, + [buildAuthedHeaders], + ); + const handleOverlayCheckout = async () => { if (!paymentHostAllowed) { setPaymentError( @@ -3512,17 +3626,53 @@ export function AccountCenter() {

- setManualTxHash(event.target.value) - } + onChange={(event) => { + setManualTxHash(event.target.value); + void validateTxHash( + manualPayment.intent_id || + lastIntentId || + "", + event.target.value, + ); + }} placeholder="0x..." className="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 font-mono text-xs text-slate-100 outline-none focus:border-emerald-400/50" /> + {txValidation.loading ? ( +

+ 验证中... +

+ ) : txValidation.checked && txValidation.valid ? ( +

+ 收款地址和金额匹配 +

+ ) : txValidation.checked && + txValidation.valid === false ? ( +

+ {txValidation.reason === + "tx_not_mined" + ? "交易未上链,请等待" + : txValidation.reason === "receiver_mismatch" + ? "收款地址不匹配!请检查是否转到了正确的地址" + : txValidation.reason === + "amount_insufficient" + ? "转账金额不足" + : txValidation.reason === "tx_reverted" + ? "该交易已回滚" + : txValidation.detail || + "验证失败: " + + (txValidation.reason || + "未知错误")} +

+ ) : null}