feat: Implement a new contract-based payment system with wallet binding, payment intents, and subscription plan management.

This commit is contained in:
2569718930@qq.com
2026-03-14 10:35:30 +08:00
parent be61c39795
commit 02e35a3a5f
10 changed files with 698 additions and 9 deletions
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(
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 auth = await buildBackendRequestHeaders(req);
const res = await fetch(
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}`,
{
method: "GET",
headers: auth.headers,
cache: "no-store",
},
);
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch payment intent", detail: String(error) },
{ status: 500 },
);
}
}
+63 -3
View File
@@ -125,6 +125,14 @@ type CreatedIntent = {
};
};
type IntentStatusResponse = {
intent?: {
intent_id?: string;
status?: string;
tx_hash?: string | null;
};
};
declare global {
interface Window {
ethereum?: EvmProvider;
@@ -398,7 +406,7 @@ function normalizePaymentError(error: unknown): NormalizedPaymentError {
?.trim();
const lower = String(rawMessage || "").toLowerCase();
if (lower.includes("confirm pending")) {
if (lower.includes("confirm pending") || lower.includes("payment pending timeout")) {
return {
message: "链上交易已提交,正在确认中,请稍后刷新查看状态。",
pending: true,
@@ -940,6 +948,55 @@ export function AccountCenter() {
throw new Error(`transaction confirmation timeout: ${txHash}`);
};
const pollIntentUntilConfirmed = useCallback(
async (
intentId: string,
authHeaders: Record<string, string>,
txHashHint = "",
timeoutMs = 180000,
pollMs = 5000,
) => {
const startedAt = Date.now();
const shortTx = shortAddress(txHashHint);
while (Date.now() - startedAt < timeoutMs) {
const statusRes = await fetch(`/api/payments/intents/${intentId}`, {
method: "GET",
headers: authHeaders,
cache: "no-store",
});
if (!statusRes.ok) {
if (statusRes.status >= 500 || statusRes.status === 429) {
await new Promise((resolve) => setTimeout(resolve, pollMs));
continue;
}
const raw = (await statusRes.text()).slice(0, 260);
throw new Error(`query intent failed: ${raw}`);
}
const statusJson = (await statusRes.json()) as IntentStatusResponse;
const intent = statusJson.intent || {};
const status = String(intent.status || "").toLowerCase();
const txHash = String(intent.tx_hash || txHashHint || "").toLowerCase();
if (status === "confirmed") {
setPaymentError("");
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHash)}`);
await loadSnapshot();
await loadPaymentSnapshot();
return;
}
if (status === "failed" || status === "cancelled" || status === "expired") {
throw new Error(`payment ${status}`);
}
setPaymentInfo(
`交易已提交: ${shortTx},正在链上确认(状态: ${status || "submitted"}...`,
);
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error("payment pending timeout");
},
[loadPaymentSnapshot, loadSnapshot],
);
const signBindMessage = async (
eth: EvmProvider,
address: string,
@@ -1296,8 +1353,11 @@ export function AccountCenter() {
(lowerRaw.includes("confirmations not enough") ||
lowerRaw.includes("tx indexed partially")));
if (maybePending) {
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待确认中。`);
throw new Error(`confirm pending: ${raw}`);
setPaymentInfo(
`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`,
);
await pollIntentUntilConfirmed(intentId, authHeaders, txHashNorm);
return;
}
throw new Error(`confirm failed: ${raw}`);
}