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}