's'
This commit is contained in:
@@ -90,9 +90,10 @@ async function createPayOrder(
|
||||
...(provider && { provider }),
|
||||
};
|
||||
const payh5Url = `${apiUrl}/nomadvip/payh5`;
|
||||
console.log("[pay] createPayOrder 请求 payjsapi url=%s", payh5Url);
|
||||
const payh5Timeout = Number(process.env.PAYJSAPI_PAYH5_TIMEOUT) || 60000;
|
||||
console.log("[pay] createPayOrder 请求 payjsapi url=%s timeout=%ds", payh5Url, payh5Timeout / 1000);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
const timeout = setTimeout(() => controller.abort(), payh5Timeout);
|
||||
const res = await fetch(payh5Url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -100,14 +101,18 @@ async function createPayOrder(
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
console.log("[pay] createPayOrder 响应 status=%s ok=%s", res.status, data?.status);
|
||||
if (!res.ok || data?.status !== "ok") {
|
||||
throw new Error(data?.detail || data?.msg || "支付创建失败");
|
||||
const statusOk = data?.status === "ok" || data?.status === "success" || (res.ok && (data?.xorpay_params || data?.params));
|
||||
console.log("[pay] createPayOrder 响应 http=%s status=%s hasParams=%s", res.status, data?.status, !!(data?.xorpay_params || data?.params));
|
||||
if (!res.ok || !statusOk) {
|
||||
const errMsg = data?.detail || data?.msg || data?.error || "支付创建失败";
|
||||
console.error("[pay] createPayOrder 失败:", errMsg, "body=", JSON.stringify(data).slice(0, 300));
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
// xorpay: xorpay_params + xorpay_url | zpay: params + pay_url
|
||||
const params = (data.xorpay_params ?? data.params) || {};
|
||||
const payUrl = data.xorpay_url || data.pay_url || "";
|
||||
if (!params || typeof params !== "object") {
|
||||
if (!params || typeof params !== "object" || Object.keys(params).length === 0) {
|
||||
console.error("[pay] createPayOrder 参数为空 data.keys=", Object.keys(data || {}));
|
||||
throw new Error("payjsapi 未返回支付参数");
|
||||
}
|
||||
return {
|
||||
@@ -149,7 +154,9 @@ export async function POST(request: NextRequest) {
|
||||
const type = String(body?.type || "vip");
|
||||
const channel = String(body?.channel || "wxpay");
|
||||
const device = String(body?.device || "pc");
|
||||
const reqProvider = String(body?.provider || "").trim().toLowerCase() || undefined;
|
||||
// 渠道规则:pc/手机浏览器 -> zpay,仅微信内 -> xorpay
|
||||
const effectiveProvider =
|
||||
device === "wechat" ? "xorpay" : "zpay";
|
||||
const accept = request.headers.get("accept") || "";
|
||||
const wantHtml = String(body?.html) === "1" || accept.includes("text/html");
|
||||
const preferJson = String(body?.prefer_json) === "1";
|
||||
@@ -175,139 +182,94 @@ export async function POST(request: NextRequest) {
|
||||
request.headers.get("x-real-ip") ||
|
||||
"";
|
||||
|
||||
const { params, payUrl, provider } = await createPayOrder(
|
||||
apiUrl,
|
||||
user_id,
|
||||
finalReturnUrl,
|
||||
total_fee,
|
||||
type,
|
||||
channel,
|
||||
clientIp,
|
||||
reqProvider
|
||||
);
|
||||
// zpay:直接走 redirect,不先调 payh5(避免 payh5 创建 zpay 订单时阻塞超时)
|
||||
// payjsapi 的 payh5/redirect 可独立调用,内部自建订单。digital/cnomadcna 仍用 payh5+redirect 流程,路由隔离互不影响。
|
||||
if (effectiveProvider === "zpay") {
|
||||
const redirectPayload = {
|
||||
user_id,
|
||||
site_id: "vip",
|
||||
total_fee,
|
||||
type,
|
||||
channel,
|
||||
return_url: finalReturnUrl,
|
||||
device,
|
||||
provider: "zpay",
|
||||
...(clientIp && { client_ip: clientIp }),
|
||||
};
|
||||
const redirectUrl = `${apiUrl}/nomadvip/payh5/redirect`;
|
||||
const redirectTimeout = Number(process.env.PAYJSAPI_REDIRECT_TIMEOUT) || 60000;
|
||||
console.log("[pay] zpay 直接 redirect url=%s timeout=%ds", redirectUrl, redirectTimeout / 1000);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), redirectTimeout);
|
||||
const redirectRes = await fetch(redirectUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(redirectPayload),
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
if (wantHtml) {
|
||||
return new NextResponse(buildErrorHtml("支付参数为空"), {
|
||||
status: 500,
|
||||
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
|
||||
const location = redirectRes.headers.get("location");
|
||||
if (location) {
|
||||
const orderId = redirectRes.headers.get("x-pay-order-id")?.trim() || "";
|
||||
if (!wantHtml || (preferJson && orderId)) {
|
||||
const res = NextResponse.json({
|
||||
ok: true,
|
||||
order_id: orderId,
|
||||
redirect_url: location,
|
||||
provider: "zpay",
|
||||
});
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
const res = NextResponse.redirect(location);
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
const resData = await redirectRes.json().catch(() => ({}));
|
||||
|
||||
if (redirectRes.status === 200 && resData?.use_form) {
|
||||
const params = (resData.params ?? resData) as Record<string, string>;
|
||||
const orderId = String(params?.out_trade_no ?? resData?.order_id ?? "").trim();
|
||||
const submitUrl = resData?.pay_url || payConfig.zpaySubmitUrl;
|
||||
const html = buildPayHtml(submitUrl, params || {}, {
|
||||
directToCashier: device === "wechat",
|
||||
});
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 });
|
||||
}
|
||||
|
||||
// wantHtml=0:返回 JSON,供微信 fetch 后直接提交表单(无中转页)
|
||||
if (!wantHtml) {
|
||||
const orderId = String(params?.order_id || params?.out_trade_no || "").trim();
|
||||
const res = NextResponse.json({
|
||||
ok: true,
|
||||
params,
|
||||
payUrl: provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl,
|
||||
provider,
|
||||
order_id: orderId,
|
||||
});
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
|
||||
// xorpay:直接 payh5 返回收银台参数,无需 redirect 中转
|
||||
const submitUrl = provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl;
|
||||
if (provider === "xorpay") {
|
||||
const orderId = String(params?.order_id || params?.out_trade_no || "").trim();
|
||||
const html = buildPayHtml(submitUrl, params as Record<string, unknown>, {
|
||||
directToCashier: device === "wechat" || device === "h5",
|
||||
});
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
|
||||
// zpay:需要 redirect 获取 device 相关跳转
|
||||
const redirectPayload = {
|
||||
user_id,
|
||||
site_id: "vip",
|
||||
total_fee,
|
||||
type,
|
||||
channel,
|
||||
return_url: finalReturnUrl,
|
||||
device,
|
||||
provider: "zpay",
|
||||
...(clientIp && { client_ip: clientIp }),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 20000);
|
||||
const redirectRes = await fetch(`${apiUrl}/nomadvip/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 orderId =
|
||||
redirectRes.headers.get("x-pay-order-id")?.trim() ||
|
||||
String(params?.order_id || params?.out_trade_no || "").trim();
|
||||
if (preferJson && orderId) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
order_id: orderId,
|
||||
redirect_url: location,
|
||||
provider: "zpay",
|
||||
});
|
||||
}
|
||||
const res = NextResponse.redirect(location);
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
const resData = await redirectRes.json().catch(() => ({}));
|
||||
|
||||
if (redirectRes.status === 200 && resData?.use_form) {
|
||||
const orderId = String(params?.out_trade_no || "").trim();
|
||||
const html = buildPayHtml(submitUrl, params as Record<string, unknown>, {
|
||||
directToCashier: device === "wechat",
|
||||
});
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
|
||||
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
|
||||
const userId = String(user_id || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||
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 || "wxpay").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>
|
||||
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
|
||||
const userId = String(user_id || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||
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 || "wxpay").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>
|
||||
@@ -433,6 +395,80 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}
|
||||
})();
|
||||
</script>
|
||||
</body></html>`;
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
|
||||
if (redirectRes.status === 400 || redirectRes.status === 500) {
|
||||
const errMsg = resData?.detail || resData?.msg || "zpay redirect 失败";
|
||||
if (wantHtml) {
|
||||
return new NextResponse(buildErrorHtml(errMsg), {
|
||||
status: redirectRes.status,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: errMsg }, { status: redirectRes.status });
|
||||
}
|
||||
|
||||
const errMsg = resData?.detail || resData?.msg || "zpay 未返回有效响应";
|
||||
if (wantHtml) {
|
||||
return new NextResponse(buildErrorHtml(errMsg), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: errMsg }, { status: 500 });
|
||||
}
|
||||
|
||||
// xorpay:调用 payh5 获取收银台参数
|
||||
const { params, payUrl, provider } = await createPayOrder(
|
||||
apiUrl,
|
||||
user_id,
|
||||
finalReturnUrl,
|
||||
total_fee,
|
||||
type,
|
||||
channel,
|
||||
clientIp,
|
||||
effectiveProvider
|
||||
);
|
||||
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
if (wantHtml) {
|
||||
return new NextResponse(buildErrorHtml("支付参数为空"), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: "支付参数为空" }, { status: 500 });
|
||||
}
|
||||
|
||||
// wantHtml=0:返回 JSON,供微信 fetch 后直接提交表单(无中转页)
|
||||
if (!wantHtml) {
|
||||
const orderId = String(params?.order_id || params?.out_trade_no || "").trim();
|
||||
const res = NextResponse.json({
|
||||
ok: true,
|
||||
params,
|
||||
payUrl: provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl,
|
||||
provider,
|
||||
order_id: orderId,
|
||||
});
|
||||
if (orderId) setPayOrderCookie(res, orderId);
|
||||
setAnonymousUserCookie(res, request, user_id);
|
||||
return res;
|
||||
}
|
||||
|
||||
// xorpay:直接 payh5 返回收银台参数,无需 redirect 中转
|
||||
const submitUrl = provider === "xorpay" ? payUrl : payConfig.zpaySubmitUrl;
|
||||
if (provider === "xorpay") {
|
||||
const orderId = String(params?.order_id || params?.out_trade_no || "").trim();
|
||||
const html = buildPayHtml(submitUrl, params as Record<string, unknown>, {
|
||||
directToCashier: device === "wechat" || device === "h5",
|
||||
});
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
@@ -442,34 +478,17 @@ h2{color:#1e293b;margin:0 0 8px;font-size:1.5rem;}
|
||||
return res;
|
||||
}
|
||||
|
||||
if (redirectRes.status === 400 || redirectRes.status === 500) {
|
||||
const errMsg = resData?.detail || resData?.msg || "支付跳转失败";
|
||||
if (wantHtml) {
|
||||
return new NextResponse(buildErrorHtml(errMsg), {
|
||||
status: redirectRes.status,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: errMsg }, { status: redirectRes.status });
|
||||
}
|
||||
|
||||
const createFailMsg = "支付创建失败";
|
||||
if (wantHtml) {
|
||||
return new NextResponse(buildErrorHtml(createFailMsg), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: false, error: createFailMsg }, { status: 500 });
|
||||
return NextResponse.json({ ok: false, error: "支付渠道异常" }, { status: 500 });
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
const msg = err.message || "";
|
||||
const payjsapiHint = "请确认 payjsapi 已启动 (npm run dev 或 python run.py,端口 8007)";
|
||||
const payjsapiHint =
|
||||
"请确认 payjsapi 已启动且 nomadvip 能访问。同机部署可设 PAYMENT_API_INTERNAL_URL=http://127.0.0.1:8007";
|
||||
const hint =
|
||||
msg.includes("fetch") || msg.includes("ECONNREFUSED")
|
||||
? payjsapiHint
|
||||
: err.name === "AbortError"
|
||||
? `请求超时,${payjsapiHint}`
|
||||
? `请求超时(30s),${payjsapiHint}`
|
||||
: msg;
|
||||
console.error("Pay API error:", err);
|
||||
const errorMsg = `支付请求失败: ${hint}`;
|
||||
|
||||
@@ -34,9 +34,10 @@ async function checkVipFromPayjsapi(
|
||||
userId
|
||||
)}`;
|
||||
console.log("[check-by-user] payjsapi request user_id=%s url=%s", userId, url);
|
||||
const checkTimeout = Number(process.env.PAYJSAPI_CHECK_TIMEOUT) || 15000;
|
||||
const res = await fetch(
|
||||
url,
|
||||
{ cache: "no-store", signal: AbortSignal.timeout(5000) }
|
||||
{ cache: "no-store", signal: AbortSignal.timeout(checkTimeout) }
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.log(
|
||||
|
||||
Reference in New Issue
Block a user