import { NextRequest, NextResponse } from "next/server"; import { getApiUrlFromRequest, getJoinPaymentConfig, getPaymentConfig, SITE_ID, } from "@/config/services.config"; import { SITE_URL } from "../../lib/env"; const ORDER_COOKIE = "join_pay_order"; function resolvePayApiUrl(request: NextRequest): string { const config = getPaymentConfig(); const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host"); return (getApiUrlFromRequest(hostHeader) || config.apiUrl).replace(/\/$/, ""); } 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) .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } function buildPayHtml( payUrl: string, params: Record, options?: { directToCashier?: boolean; orderId?: string } ): string { const inputs = Object.entries(params) .filter(([, value]) => value != null && String(value).trim() !== "") .map( ([key, value]) => `` ) .join("\n"); const pendingOrderScript = options?.orderId ? `` : ""; return options?.directToCashier ? `支付
${inputs}
${pendingOrderScript}` : `跳转支付

正在跳转到支付页面...

${inputs}
${pendingOrderScript}`; } function buildQrHtml( imgSrc: string, returnUrl: string, orderId: string, opts?: { channel?: string; successDesc?: string; confirmUrl?: string } ): string { const ch = (opts?.channel || "wxpay").toLowerCase(); const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat"; const title = isWx ? "微信扫码支付" : "支付宝扫码支付"; const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付"; const successDesc = opts?.successDesc || "支付成功,即将跳转"; const confirmUrl = opts?.confirmUrl || "/api/pay/confirm"; const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c); return ` ${esc(title)}

${esc(title)}

${esc(hint)}

5:00
支付二维码

等待支付中...

支付成功
${esc(successDesc)}
3 秒后跳转
`; } async function createPayOrder( apiUrl: string, userId: string, returnUrl: string, totalFee: number, type: string, channel: string, provider?: "xorpay", clientIp?: string ) { const payload: Record = { 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, 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 ) { 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) { try { let body: Record; const contentType = request.headers.get("content-type") || ""; if (contentType.includes("application/json")) { body = (await request.json()) as Record; } else { const form = await request.formData(); body = Object.fromEntries(form.entries()) as Record; } const joinConfig = getJoinPaymentConfig(); const payConfig = getPaymentConfig(); 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 || "wxpay"); const device = String(body?.device || "pc"); const forcedProvider = device === "wechat" ? "xorpay" : undefined; 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; 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"); console.log( "[pay] digital apiUrl=%s host=%s provider=%s device=%s", apiUrl, hostHeader, forcedProvider || "default", device ); 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; } if (created.provider === "xorpay") { const html = buildPayHtml(created.payUrl, created.params, { directToCashier: true, orderId: created.orderId, }); const response = new NextResponse(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" }, }); if (created.orderId) setPayOrderCookie(response, created.orderId); return response; } const redirectPayload: Record = { 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, { channel: resData.channel || "wxpay", successDesc: "支付成功,课程已解锁", }); 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, }); 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") ? "请确认 payjsapi 已启动:cd C:\\project\\payjsapi && python run.py" : err.name === "AbortError" ? "请求超时,请检查 payjsapi 是否正常运行" : msg; console.error("Pay API error:", err); return NextResponse.json( { ok: false, error: `支付请求失败: ${hint}` }, { status: 500 } ); } }