From 105713c79930ef5005d8b8221f7790cfc7e80426 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 22 May 2026 04:44:14 +0800 Subject: [PATCH] =?UTF-8?q?@=20=E6=94=AF=E4=BB=98=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20Tx=20=E9=A2=84=E6=A0=A1=E9=AA=8C=EF=BC=9A?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=89=8D=E9=93=BE=E4=B8=8A=E9=AA=8C=E7=AD=BE?= =?UTF-8?q?=E6=94=B6=E6=AC=BE=E5=9C=B0=E5=9D=80=E4=B8=8E=E9=87=91=E9=A2=9D?= =?UTF-8?q?=EF=BC=8C=E9=98=B2=E6=AD=A2=E8=BD=AC=E9=94=99=E5=9C=B0=E5=9D=80?= =?UTF-8?q?=EF=BC=9B409=20=E9=94=99=E8=AF=AF=E5=B1=95=E7=A4=BA=E5=8F=8B?= =?UTF-8?q?=E5=A5=BD=E4=B8=AD=E6=96=87=E6=8F=90=E7=A4=BA=E5=B9=B6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=AF=B9=E8=B4=A6=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 validate_intent_tx 方法及 POST /api/payments/intents/{id}/validate 端点, 在提交前查链上 receipt 对比收款地址和金额,mismatch 直接拦截 - 新增 handleSubmit409 辅助函数,根据后端错误详情分流处理: 已支付→自动 reconcile,已过期→提示重下单,其他→透传具体原因 - submit/validate 路由透传后端 detail 字段,生产环境也能看到具体错误 - 手动转账面板粘贴 tx hash 后自动触发验证,绿色/红色提示, 验证不通过时禁用提交按钮 Tested: tsc --noEmit + ruff check . 均通过 @ --- .../intents/[intentId]/submit/route.ts | 6 + .../intents/[intentId]/validate/route.ts | 59 +++++++ frontend/components/account/AccountCenter.tsx | 158 ++++++++++++++++- src/payments/contract_checkout.py | 162 ++++++++++++++++++ web/core.py | 4 + web/routers/payments.py | 11 ++ web/services/payment_api.py | 17 ++ 7 files changed, 413 insertions(+), 4 deletions(-) create mode 100644 frontend/app/api/payments/intents/[intentId]/validate/route.ts 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}