s'c1'
This commit is contained in:
170
app/api/pay/confirm/route.ts
Normal file
170
app/api/pay/confirm/route.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 支付确认:对齐 nomadvip 标准。payjsapi 异步通知未到达时,由前端直接写入 PocketBase site_vip
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getApiUrlFromRequest,
|
||||
getPocketBaseConfig,
|
||||
getPaymentConfig,
|
||||
getJoinPaymentConfig,
|
||||
SITE_ID,
|
||||
} from "@/config/services.config";
|
||||
import { queryXorPayOrderStatus } from "@/app/lib/payment/xorpayStatus";
|
||||
import { queryZPayOrderStatus } from "@/app/lib/payment/zpayStatus";
|
||||
|
||||
function parseUserIdFromOrderId(orderId: string): string | null {
|
||||
if (!orderId || !orderId.includes("_order_")) return null;
|
||||
const prefix = orderId.split("_order_")[0];
|
||||
if (!prefix || !prefix.includes("_")) return prefix || null;
|
||||
const parts = prefix.split("_");
|
||||
const type = parts[parts.length - 1];
|
||||
if (["vip", "video", "meetup", "book"].includes(type?.toLowerCase() || "")) {
|
||||
return parts.slice(0, -1).join("_") || null;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
async function getAdminToken(): Promise<string | null> {
|
||||
const email = process.env.POCKETBASE_EMAIL || process.env.PB_ADMIN_EMAIL;
|
||||
const password = process.env.POCKETBASE_PASSWORD || process.env.PB_ADMIN_PASSWORD;
|
||||
if (!email || !password) return null;
|
||||
const pb = getPocketBaseConfig();
|
||||
const paths = ["/api/admins/auth-with-password", "/api/collections/_superusers/auth-with-password"];
|
||||
for (const path of paths) {
|
||||
const res = await fetch(`${pb.url}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identity: email, password }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data?.token) return data.token;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePayApiUrl(request: NextRequest): string {
|
||||
const payConfig = getPaymentConfig();
|
||||
const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
return (
|
||||
(hostHeader ? getApiUrlFromRequest(hostHeader) : null) ||
|
||||
process.env.PAYMENT_API_URL ||
|
||||
payConfig.apiUrl
|
||||
).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
async function verifyOrderPaid(request: NextRequest, orderId: string): Promise<boolean> {
|
||||
const apiUrl = resolvePayApiUrl(request);
|
||||
const url = `${apiUrl}/digital/order_status?order_id=${encodeURIComponent(orderId)}`;
|
||||
try {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data?.paid) return true;
|
||||
} catch (e) {
|
||||
console.error("[pay/confirm] verifyOrderPaid fetch error:", e);
|
||||
}
|
||||
if (await queryXorPayOrderStatus(orderId, 5000).then((r) => r?.paid)) return true;
|
||||
if (await queryZPayOrderStatus(orderId, 5000).then((r) => r?.paid)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
let orderId = String(body?.order_id ?? body?.orderId ?? "").trim();
|
||||
let userId = String(body?.user_id ?? body?.userId ?? "").trim();
|
||||
|
||||
if (!orderId) {
|
||||
return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 });
|
||||
}
|
||||
if (!userId) {
|
||||
userId = parseUserIdFromOrderId(orderId) || "";
|
||||
}
|
||||
if (!userId) {
|
||||
return NextResponse.json({ ok: false, error: "缺少 user_id 且无法从 order_id 解析" }, { status: 400 });
|
||||
}
|
||||
|
||||
const paid = await verifyOrderPaid(request, orderId);
|
||||
if (!paid) {
|
||||
return NextResponse.json({ ok: false, error: "订单未支付" }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminToken = await getAdminToken();
|
||||
if (!adminToken) {
|
||||
return NextResponse.json({ ok: false, error: "PocketBase 未配置" }, { status: 500 });
|
||||
}
|
||||
|
||||
const pb = getPocketBaseConfig();
|
||||
const joinConfig = getJoinPaymentConfig();
|
||||
const totalFee = Number(joinConfig?.amount ?? 100);
|
||||
|
||||
const checkPayments = await fetch(
|
||||
`${pb.url}/api/collections/payments/records?filter=${encodeURIComponent(`order_id="${orderId}"`)}&perPage=1`,
|
||||
{ headers: { Authorization: `Bearer ${adminToken}` } }
|
||||
);
|
||||
const paymentsData = await checkPayments.json().catch(() => ({}));
|
||||
if ((paymentsData?.items?.length ?? 0) === 0) {
|
||||
await fetch(`${pb.url}/api/collections/payments/records`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_id: userId,
|
||||
type: "video",
|
||||
order_id: orderId,
|
||||
total_fee: totalFee,
|
||||
amount: totalFee,
|
||||
channel: "wxpay",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const siteVipCheck = await fetch(
|
||||
`${pb.url}/api/collections/site_vip/records?filter=${encodeURIComponent(`user_id="${userId}" && site_id="${SITE_ID}"`)}&perPage=1`,
|
||||
{ headers: { Authorization: `Bearer ${adminToken}` } }
|
||||
);
|
||||
const siteVipData = await siteVipCheck.json().catch(() => ({}));
|
||||
const siteVipItems = siteVipData?.items ?? [];
|
||||
|
||||
if (siteVipItems.length > 0) {
|
||||
await fetch(`${pb.url}/api/collections/site_vip/records/${siteVipItems[0].id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
body: JSON.stringify({ order_id: orderId }),
|
||||
});
|
||||
} else {
|
||||
const siteVipRes = await fetch(`${pb.url}/api/collections/site_vip/records`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_id: userId,
|
||||
site_id: SITE_ID,
|
||||
order_id: orderId,
|
||||
}),
|
||||
});
|
||||
if (!siteVipRes.ok) {
|
||||
const err = await siteVipRes.json().catch(() => ({}));
|
||||
if (!err?.data?.order_id?.message?.includes("unique") && !err?.message?.includes("unique")) {
|
||||
console.error("[pay/confirm] site_vip create failed:", await siteVipRes.text());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error("[pay/confirm] error:", e);
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: e instanceof Error ? e.message : "确认失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPaymentConfig, getJoinPaymentConfig, SITE_ID } from "@/config/services.config";
|
||||
import {
|
||||
getApiUrlFromRequest,
|
||||
getJoinPaymentConfig,
|
||||
getPaymentConfig,
|
||||
SITE_ID,
|
||||
} from "@/config/services.config";
|
||||
import { SITE_URL } from "../../lib/env";
|
||||
|
||||
async function createPayOrder(
|
||||
user_id: string,
|
||||
return_url: string,
|
||||
total_fee?: number,
|
||||
type?: string,
|
||||
channel = "alipay"
|
||||
) {
|
||||
const joinConfig = getJoinPaymentConfig();
|
||||
const fee = total_fee ?? joinConfig.amount;
|
||||
const orderType = type ?? joinConfig.type;
|
||||
const ORDER_COOKIE = "join_pay_order";
|
||||
|
||||
function resolvePayApiUrl(request: NextRequest): string {
|
||||
const config = getPaymentConfig();
|
||||
if (!config.apiUrl) throw new Error("未配置 PAYMENT_API_URL");
|
||||
|
||||
const payload = {
|
||||
user_id,
|
||||
site_id: SITE_ID,
|
||||
total_fee: Number(fee) || joinConfig.amount,
|
||||
type: orderType,
|
||||
channel,
|
||||
return_url,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const res = await fetch(`${config.apiUrl}/digital/payh5`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data?.status !== "ok") {
|
||||
throw new Error(data?.detail || data?.msg || data?.message || "支付创建失败");
|
||||
}
|
||||
|
||||
let params = data.params || data.xorpay_params || {};
|
||||
const payUrl = data.pay_url || data.xorpay_url || config.zpaySubmitUrl;
|
||||
if (!params || typeof params !== "object") {
|
||||
throw new Error("payjsapi 未返回支付参数");
|
||||
}
|
||||
params = JSON.parse(JSON.stringify(params));
|
||||
if (data.provider === "xorpay") {
|
||||
throw new Error("当前 payjsapi 使用 xorpay,请设置 PAYMENT_PROVIDER=zpay 后重启 payjsapi");
|
||||
}
|
||||
return { params, payUrl };
|
||||
const hostHeader =
|
||||
request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
return (
|
||||
getApiUrlFromRequest(hostHeader) ||
|
||||
process.env.PAYMENT_API_URL ||
|
||||
config.apiUrl
|
||||
).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
const ZPAY_REQUIRED = ["pid", "type", "out_trade_no", "notify_url", "return_url", "name", "money", "sign", "sign_type"];
|
||||
function setPayOrderCookie(response: NextResponse, orderId: string) {
|
||||
response.cookies.set(ORDER_COOKIE, `${orderId}|${Date.now()}`, {
|
||||
path: "/",
|
||||
maxAge: 900,
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return String(s)
|
||||
@@ -61,39 +35,142 @@ function escapeAttr(s: string): string {
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function buildPayHtml(payUrl: string, params: Record<string, unknown>): string {
|
||||
const flat: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v == null) continue;
|
||||
const s = String(v).trim();
|
||||
if (s === "") continue;
|
||||
flat[k] = s;
|
||||
}
|
||||
for (const key of ZPAY_REQUIRED) {
|
||||
if (!flat[key]) {
|
||||
console.error(`ZPAY 缺少必填参数: ${key}`, flat);
|
||||
}
|
||||
}
|
||||
const inputs = Object.entries(flat)
|
||||
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(v)}" />`)
|
||||
function buildPayHtml(
|
||||
payUrl: string,
|
||||
params: Record<string, unknown>,
|
||||
options?: { directToCashier?: boolean; orderId?: string }
|
||||
): string {
|
||||
const inputs = Object.entries(params)
|
||||
.filter(([, value]) => value != null && String(value).trim() !== "")
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`<input type="hidden" name="${escapeAttr(key)}" value="${escapeAttr(
|
||||
String(value)
|
||||
)}" />`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const pendingOrderScript = options?.orderId
|
||||
? `<script>(function(){var v=${JSON.stringify(
|
||||
`${options.orderId}|${Date.now()}`
|
||||
)};try{localStorage.setItem("join_pay_order",v);}catch(e){}document.cookie="join_pay_order="+encodeURIComponent(v)+"; path=/; max-age=900";})();</script>`
|
||||
: "";
|
||||
|
||||
return options?.directToCashier
|
||||
? `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>支付</title><style>body{margin:0;overflow:hidden}</style></head><body><form id="f" action="${escapeAttr(
|
||||
payUrl
|
||||
)}" method="POST">${inputs}</form>${pendingOrderScript}<script>document.getElementById("f").submit()</script></body></html>`
|
||||
: `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>跳转支付</title><style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style></head><body><p>正在跳转到支付页面...</p><form id="f" action="${escapeAttr(
|
||||
payUrl
|
||||
)}" method="POST">${inputs}</form>${pendingOrderScript}<script>document.getElementById("f").submit()</script></body></html>`;
|
||||
}
|
||||
|
||||
function buildQrHtml(
|
||||
imgSrc: string,
|
||||
returnUrl: string,
|
||||
orderId: string
|
||||
): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>跳转支付...</title>
|
||||
<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style>
|
||||
</head>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>扫码支付</title>
|
||||
<style>body{font-family:system-ui;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8;}h2{color:#333;margin-bottom:8px;}p{color:#64748b;font-size:14px;}img{width:220px;height:220px;margin:20px 0;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}</style></head>
|
||||
<body>
|
||||
<p>正在跳转到支付页面...</p>
|
||||
<form id="zpayForm" action="${escapeAttr(payUrl)}" method="POST" enctype="application/x-www-form-urlencoded">
|
||||
${inputs}
|
||||
</form>
|
||||
<script>document.getElementById("zpayForm").submit();</script>
|
||||
</body>
|
||||
</html>`;
|
||||
<h2>请扫码支付</h2>
|
||||
<p>支付成功后将自动跳转</p>
|
||||
<img src="${imgSrc}" alt="支付二维码" />
|
||||
<script>
|
||||
(function(){
|
||||
try {
|
||||
localStorage.setItem("join_pay_order", ${JSON.stringify(
|
||||
`${orderId}|${Date.now()}`
|
||||
)});
|
||||
} catch (e) {}
|
||||
var orderId=${JSON.stringify(orderId)};
|
||||
var returnUrl=${JSON.stringify(returnUrl)};
|
||||
if(!orderId){return;}
|
||||
function check(){
|
||||
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId), { cache: "no-store" })
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if(d.ok&&d.paid){window.location.href=returnUrl;return;}
|
||||
setTimeout(check,1200);
|
||||
})
|
||||
.catch(function(){setTimeout(check,1200);});
|
||||
}
|
||||
check();
|
||||
})();
|
||||
</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
async function createPayOrder(
|
||||
apiUrl: string,
|
||||
userId: string,
|
||||
returnUrl: string,
|
||||
totalFee: number,
|
||||
type: string,
|
||||
channel: string,
|
||||
provider?: "xorpay",
|
||||
clientIp?: string
|
||||
) {
|
||||
const payload: Record<string, unknown> = {
|
||||
user_id: userId,
|
||||
site_id: SITE_ID,
|
||||
total_fee: totalFee,
|
||||
type,
|
||||
channel,
|
||||
return_url: returnUrl,
|
||||
...(provider ? { provider } : {}),
|
||||
...(clientIp ? { client_ip: clientIp } : {}),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
const res = await fetch(`${apiUrl}/digital/payh5`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data?.status !== "ok") {
|
||||
console.error("[pay] createPayOrder failed status=%s body=%j", res.status, data);
|
||||
throw new Error(data?.detail || data?.msg || data?.message || "支付创建失败");
|
||||
}
|
||||
|
||||
return {
|
||||
params: (data.params ?? data.xorpay_params ?? {}) as Record<string, string>,
|
||||
payUrl:
|
||||
data.pay_url || data.xorpay_url || getPaymentConfig().zpaySubmitUrl,
|
||||
provider: String(data.provider || (provider ? "xorpay" : "zpay")).trim(),
|
||||
orderId: String(
|
||||
data.order_id ||
|
||||
data.params?.out_trade_no ||
|
||||
data.xorpay_params?.order_id ||
|
||||
""
|
||||
).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function requestRedirect(
|
||||
apiUrl: string,
|
||||
path: string,
|
||||
payload: Record<string, unknown>
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 12000);
|
||||
try {
|
||||
return await fetch(`${apiUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -101,7 +178,7 @@ export async function POST(request: NextRequest) {
|
||||
let body: Record<string, string | number>;
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
body = await request.json();
|
||||
body = (await request.json()) as Record<string, string | number>;
|
||||
} else {
|
||||
const form = await request.formData();
|
||||
body = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||
@@ -109,225 +186,176 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const joinConfig = getJoinPaymentConfig();
|
||||
const payConfig = getPaymentConfig();
|
||||
const user_id = String(body?.user_id || "").trim();
|
||||
const return_url = String(body?.return_url || "").trim();
|
||||
const total_fee = Number(body?.total_fee) || joinConfig.amount;
|
||||
const hostHeader =
|
||||
request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
const apiUrl = resolvePayApiUrl(request);
|
||||
const userId = String(body?.user_id || "").trim();
|
||||
const totalFee = Number(body?.total_fee) || joinConfig.amount;
|
||||
const type = String(body?.type || joinConfig.type);
|
||||
const channel = String(body?.channel || "alipay");
|
||||
const device = String(body?.device || "pc"); // pc | h5 | wechat,微信内传 wechat
|
||||
const channel = String(body?.channel || "wxpay");
|
||||
const device = String(body?.device || "pc");
|
||||
const forcedProvider = device === "wechat" ? "xorpay" : undefined;
|
||||
|
||||
if (!user_id) {
|
||||
return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 400 });
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "缺少 user_id" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const origin =
|
||||
request.headers.get("x-forwarded-host")
|
||||
? `${request.headers.get("x-forwarded-proto") || "https"}://${request.headers.get("x-forwarded-host")}`
|
||||
: request.headers.get("origin") || SITE_URL;
|
||||
// Z-Pay return_url 不支持带参数,使用 /join/paid 专用路径
|
||||
const finalReturnUrl = return_url || `${origin}/zh/join/paid`;
|
||||
const returnUrl =
|
||||
String(body?.return_url || "").trim() || `${origin}/zh/join/paid`;
|
||||
const clientIp =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"";
|
||||
const wantHtml =
|
||||
String(body?.html) === "1" ||
|
||||
(request.headers.get("accept") || "").includes("text/html");
|
||||
|
||||
const { params, payUrl } = await createPayOrder(
|
||||
user_id,
|
||||
finalReturnUrl,
|
||||
total_fee,
|
||||
type,
|
||||
channel
|
||||
console.log(
|
||||
"[pay] digital apiUrl=%s host=%s provider=%s device=%s",
|
||||
apiUrl,
|
||||
hostHeader,
|
||||
forcedProvider || "default",
|
||||
device
|
||||
);
|
||||
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 });
|
||||
const created = await createPayOrder(
|
||||
apiUrl,
|
||||
userId,
|
||||
returnUrl,
|
||||
totalFee,
|
||||
type,
|
||||
channel,
|
||||
forcedProvider,
|
||||
clientIp
|
||||
);
|
||||
|
||||
if (!wantHtml) {
|
||||
const response = NextResponse.json({
|
||||
ok: true,
|
||||
params: created.params,
|
||||
payUrl: created.payUrl,
|
||||
provider: created.provider,
|
||||
order_id: created.orderId,
|
||||
});
|
||||
if (created.orderId) setPayOrderCookie(response, created.orderId);
|
||||
return response;
|
||||
}
|
||||
|
||||
const accept = request.headers.get("accept") || "";
|
||||
const wantHtml = String(body?.html) === "1" || accept.includes("text/html");
|
||||
if (wantHtml) {
|
||||
const clientIp =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"";
|
||||
// 使用 payjsapi /payh5/redirect:mapi.php API接口支付,按 device 返回 payurl/payurl2
|
||||
const redirectPayload = {
|
||||
user_id,
|
||||
site_id: SITE_ID,
|
||||
total_fee,
|
||||
type,
|
||||
channel,
|
||||
return_url: finalReturnUrl,
|
||||
device,
|
||||
...(clientIp && { client_ip: clientIp }),
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
const redirectRes = await fetch(`${payConfig.apiUrl}/digital/payh5/redirect`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(redirectPayload),
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
|
||||
const location = redirectRes.headers.get("location");
|
||||
if (location) {
|
||||
const res = NextResponse.redirect(location);
|
||||
// payh5/redirect 创建的是新订单,必须用其返回的 order_id(非 createPayOrder 的 params)
|
||||
const orderId =
|
||||
redirectRes.headers.get("x-pay-order-id")?.trim() ||
|
||||
String(params?.out_trade_no || "").trim();
|
||||
if (orderId) {
|
||||
// 附带时间戳,前端只对 15 分钟内设置的 cookie 轮询,避免陈旧 cookie 误触发
|
||||
const payload = `${orderId}|${Date.now()}`;
|
||||
res.cookies.set("join_pay_order", payload, { path: "/", maxAge: 900 });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
const resData = await redirectRes.json().catch(() => ({}));
|
||||
// 微信内:payurl2 会提示「请外微信外打开」,改用收银台表单由 Z-Pay 识别 UA
|
||||
if (redirectRes.status === 200 && resData?.use_form) {
|
||||
const html = buildPayHtml(payConfig.zpaySubmitUrl, params as Record<string, unknown>);
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
const orderId =
|
||||
resData?.order_id?.trim() || String(params?.out_trade_no || "").trim();
|
||||
if (orderId) {
|
||||
res.cookies.set("join_pay_order", `${orderId}|${Date.now()}`, {
|
||||
path: "/",
|
||||
maxAge: 900,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
|
||||
const returnUrl = String(resData.return_url || finalReturnUrl).replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const imgSrc = String(resData.img).replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const orderId = String(resData.order_id || "").replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const ch = String(resData.channel || "alipay").toLowerCase();
|
||||
const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat";
|
||||
const title = isWx ? "微信扫码支付" : "支付宝扫码支付";
|
||||
const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付";
|
||||
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${esc(title)}</title>
|
||||
<style>body{font-family:system-ui;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8;}h2{color:#333;margin-bottom:8px;}p{color:#64748b;font-size:14px;}img{width:220px;height:220px;margin:20px 0;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}a{color:#0ea5e9;margin-top:16px;}.tip{font-size:12px;color:#94a3b8;margin-top:8px;}</style></head>
|
||||
<body>
|
||||
<h2>${esc(title)}</h2>
|
||||
<p>${esc(hint)}</p>
|
||||
<img src="${imgSrc}" alt="支付二维码" />
|
||||
<p class="tip" id="tip">支付成功后将自动跳转...</p>
|
||||
<script>
|
||||
(function(){
|
||||
var orderId="${orderId}";
|
||||
var returnUrl="${returnUrl}";
|
||||
if(!orderId){return;}
|
||||
var tip=document.getElementById("tip");
|
||||
var t=0;
|
||||
var maxT=300;
|
||||
function check(){
|
||||
t++;
|
||||
if(t>maxT){tip.textContent="轮询超时";return;}
|
||||
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId))
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if(d.ok&&d.paid){window.location.href=returnUrl;return;}
|
||||
setTimeout(check,2000);
|
||||
})
|
||||
.catch(function(){setTimeout(check,2000);});
|
||||
}
|
||||
setTimeout(check,2000);
|
||||
})();
|
||||
</script>
|
||||
</body></html>`;
|
||||
return new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
const errData = resData;
|
||||
if (redirectRes.status === 400 || redirectRes.status === 500) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: errData?.detail || errData?.msg || "支付跳转失败" },
|
||||
{ status: redirectRes.status }
|
||||
);
|
||||
}
|
||||
// 降级:payjsapi 未提供 redirect 或非 zpay,使用原有表单提交逻辑
|
||||
const formParams = new URLSearchParams();
|
||||
const missing: string[] = [];
|
||||
for (const key of ZPAY_REQUIRED) {
|
||||
const v = params[key];
|
||||
if (v == null || String(v).trim() === "") {
|
||||
missing.push(key);
|
||||
} else {
|
||||
formParams.append(key, String(v));
|
||||
}
|
||||
}
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!ZPAY_REQUIRED.includes(k) && v != null && String(v).trim() !== "") {
|
||||
formParams.append(k, String(v));
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: `payjsapi 返回参数缺少: ${missing.join(", ")}。请确认 payjsapi 已设置 PAYMENT_PROVIDER=zpay`,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const zpayRes = await fetch(payConfig.zpaySubmitUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: formParams.toString(),
|
||||
redirect: "manual",
|
||||
if (created.provider === "xorpay") {
|
||||
const html = buildPayHtml(created.payUrl, created.params, {
|
||||
directToCashier: true,
|
||||
orderId: created.orderId,
|
||||
});
|
||||
const location = zpayRes.headers.get("location");
|
||||
if ((zpayRes.status === 301 || zpayRes.status === 302) && location) {
|
||||
const res = NextResponse.redirect(location);
|
||||
const outTradeNo = String(params?.out_trade_no || "").trim();
|
||||
if (outTradeNo) {
|
||||
const payload = `${outTradeNo}|${Date.now()}`;
|
||||
res.cookies.set("join_pay_order", payload, { path: "/", maxAge: 900 });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
const text = await zpayRes.text();
|
||||
try {
|
||||
const errJson = JSON.parse(text);
|
||||
if (errJson?.code === "error") {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: errJson.msg || "ZPAY 返回错误" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const html = buildPayHtml(payUrl, params as Record<string, unknown>);
|
||||
return new NextResponse(html, {
|
||||
const response = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
if (created.orderId) setPayOrderCookie(response, created.orderId);
|
||||
return response;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
pay_url: payUrl,
|
||||
params,
|
||||
provider: "zpay",
|
||||
submit_method: "POST",
|
||||
const redirectPayload: Record<string, unknown> = {
|
||||
user_id: userId,
|
||||
site_id: SITE_ID,
|
||||
total_fee: totalFee,
|
||||
type,
|
||||
channel,
|
||||
return_url: returnUrl,
|
||||
device,
|
||||
...(clientIp ? { client_ip: clientIp } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const redirectRes = await requestRedirect(
|
||||
apiUrl,
|
||||
"/digital/payh5/redirect",
|
||||
redirectPayload
|
||||
);
|
||||
|
||||
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
|
||||
const location = redirectRes.headers.get("location");
|
||||
if (location) {
|
||||
const response = NextResponse.redirect(location);
|
||||
const orderId =
|
||||
redirectRes.headers.get("x-pay-order-id")?.trim() ||
|
||||
created.orderId;
|
||||
if (orderId) setPayOrderCookie(response, orderId);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
const resData = await redirectRes.json().catch(() => ({}));
|
||||
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
|
||||
const safeReturnUrl = String(resData.return_url || returnUrl).replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[
|
||||
c
|
||||
] || c)
|
||||
);
|
||||
const imgSrc = String(resData.img).replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[
|
||||
c
|
||||
] || c)
|
||||
);
|
||||
const rawOrderId = String(resData.order_id || created.orderId).trim();
|
||||
const html = buildQrHtml(imgSrc, safeReturnUrl, rawOrderId);
|
||||
const response = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
if (rawOrderId) setPayOrderCookie(response, rawOrderId);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (redirectRes.status === 200 && resData?.use_form) {
|
||||
const html = buildPayHtml(payConfig.zpaySubmitUrl, created.params, {
|
||||
directToCashier: true,
|
||||
orderId: String(resData.order_id || created.orderId).trim(),
|
||||
});
|
||||
const response = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
const orderId = String(resData.order_id || created.orderId).trim();
|
||||
if (orderId) setPayOrderCookie(response, orderId);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (redirectRes.status >= 400) {
|
||||
console.warn(
|
||||
"[pay] redirect fallback status=%s body=%j",
|
||||
redirectRes.status,
|
||||
resData
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[pay] redirect fallback error=%s",
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
|
||||
const html = buildPayHtml(created.payUrl, created.params, {
|
||||
orderId: created.orderId,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
const response = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
if (created.orderId) setPayOrderCookie(response, created.orderId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
const msg = err.message || "";
|
||||
const hint =
|
||||
msg.includes("fetch") || msg.includes("ECONNREFUSED")
|
||||
|
||||
@@ -1,25 +1,88 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPaymentConfig } from "@/config/services.config";
|
||||
import {
|
||||
getApiUrlFromRequest,
|
||||
getPaymentConfig,
|
||||
} from "@/config/services.config";
|
||||
import { queryXorPayOrderStatus } from "@/app/lib/payment/xorpayStatus";
|
||||
import { queryZPayOrderStatus } from "@/app/lib/payment/zpayStatus";
|
||||
|
||||
function resolvePayApiUrl(request: NextRequest): string {
|
||||
const config = getPaymentConfig();
|
||||
const hostHeader =
|
||||
request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
return (
|
||||
getApiUrlFromRequest(hostHeader) ||
|
||||
process.env.PAYMENT_API_URL ||
|
||||
config.apiUrl
|
||||
).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const orderId = request.nextUrl.searchParams.get("order_id");
|
||||
if (!orderId) {
|
||||
return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 });
|
||||
}
|
||||
const config = getPaymentConfig();
|
||||
if (!config.apiUrl) {
|
||||
return NextResponse.json({ ok: false, error: "未配置 PAYMENT_API_URL" }, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "missing order_id" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const hostHeader =
|
||||
request.headers.get("host") || request.headers.get("x-forwarded-host");
|
||||
const apiUrl = resolvePayApiUrl(request);
|
||||
const url = `${apiUrl}/digital/order_status?order_id=${encodeURIComponent(orderId)}`;
|
||||
console.log("[pay/status] request order_id=%s host=%s url=%s", orderId, hostHeader, url);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 25000);
|
||||
const res = await fetch(
|
||||
`${config.apiUrl}/digital/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
|
||||
{ cache: "no-store", signal: controller.signal }
|
||||
).finally(() => clearTimeout(timeout));
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return NextResponse.json({ ok: true, paid: !!data?.paid });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: true, paid: false });
|
||||
const paid = !!data?.paid;
|
||||
console.log(
|
||||
"[pay/status] result order_id=%s paid=%s status=%s",
|
||||
orderId,
|
||||
paid,
|
||||
res.status
|
||||
);
|
||||
if (paid) {
|
||||
return NextResponse.json({ ok: true, paid: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"[pay/status] error order_id=%s error=%s",
|
||||
orderId,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
|
||||
const xorpay = await queryXorPayOrderStatus(orderId, 5000);
|
||||
console.log(
|
||||
"[pay/status] xorpay fallback order_id=%s paid=%s status=%s error=%s url=%s",
|
||||
orderId,
|
||||
!!xorpay?.paid,
|
||||
xorpay?.status || "",
|
||||
xorpay?.error || "",
|
||||
xorpay?.url || ""
|
||||
);
|
||||
if (xorpay?.paid) {
|
||||
return NextResponse.json({ ok: true, paid: true });
|
||||
}
|
||||
|
||||
const zpay = await queryZPayOrderStatus(orderId, 5000);
|
||||
console.log(
|
||||
"[pay/status] zpay fallback order_id=%s paid=%s status=%s error=%s url=%s",
|
||||
orderId,
|
||||
!!zpay?.paid,
|
||||
zpay?.status || "",
|
||||
zpay?.error || "",
|
||||
zpay?.url || ""
|
||||
);
|
||||
if (zpay?.paid) {
|
||||
return NextResponse.json({ ok: true, paid: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, paid: false });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user