/** * 共享支付触发逻辑 - 与首页「立即加入」效果一致 * 供首页、课程页、云手机页等所有「立即报名」按钮使用,直接发起支付,不跳转首页 * * 校验逻辑: * - 页面有本地存储(登录资料 + paidType=vip)时,优先使用本地存储并立即展示;后台悄悄校验,若 user 被删或 VIP 被撤则更新本地状态 * - 无身份标识和 VIP 权限等存储时,点击按钮直接进入支付流程,不校验本地 userId */ import { getPayEnv, getDeviceFromEnv } from "@/app/lib/payEnv"; import { getPaymentConfig } from "@/config/services.config"; const ANONYMOUS_USER_COOKIE = "nomadvip_anon_uid"; const ANONYMOUS_USER_COOKIE_MAX_AGE = 60 * 60 * 24 * 30; const PAY_ORDER_COOKIE = "nomadvip_pay_order"; const PAY_ORDER_MAX_AGE = 60 * 15; const VIP_PAY_TEST_MODE_KEY = "vipPayTestMode"; const PAY_LAUNCH_OVERLAY_ID = "nomadvip-pay-launch-overlay"; function getStorage(key: string): string | null { if (typeof window === "undefined") return null; return localStorage.getItem(key); } function setStorage(key: string, value: string): void { if (typeof window === "undefined") return; localStorage.setItem(key, value); } function getCookie(name: string): string | null { if (typeof document === "undefined") return null; const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); return match ? decodeURIComponent(match[1]) : null; } function setAnonymousUserCookie(userId: string): void { if (typeof document === "undefined") return; if (!/^user\d+$/.test(userId)) return; const secure = window.location.protocol === "https:" ? "; Secure" : ""; document.cookie = `${ANONYMOUS_USER_COOKIE}=${encodeURIComponent( userId )}; Path=/; Max-Age=${ANONYMOUS_USER_COOKIE_MAX_AGE}; SameSite=Lax${secure}`; } function setPayOrderCookie(orderId: string): void { if (typeof document === "undefined") return; const normalizedOrderId = orderId.trim(); if (!normalizedOrderId) return; const secure = window.location.protocol === "https:" ? "; Secure" : ""; document.cookie = `${PAY_ORDER_COOKIE}=${encodeURIComponent( `${normalizedOrderId}|${Date.now()}` )}; Path=/; Max-Age=${PAY_ORDER_MAX_AGE}; SameSite=Lax${secure}`; } function getDisplayProfile() { const wechatNick = (getStorage("wechatNick") || "").trim(); const userName = (getStorage("userName") || "").trim(); const userEmail = (getStorage("userEmail") || "").trim(); const wechatAvatar = (getStorage("wechatAvatar") || "").trim(); const displayName = wechatNick || userName || userEmail || "当前用户"; return { displayName, avatar: wechatAvatar || "" }; } function showPayLaunchOverlay(displayName: string, avatar?: string): void { if (typeof document === "undefined") return; if (!(avatar || "").trim()) return; const prev = document.getElementById(PAY_LAUNCH_OVERLAY_ID); if (prev) prev.remove(); const safeName = displayName.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c); const safeAvatar = (avatar || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c); const node = document.createElement("div"); node.id = PAY_LAUNCH_OVERLAY_ID; node.style.cssText = "position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(15,23,42,.28);pointer-events:none;"; node.innerHTML = `
${safeName}
开通对象:${safeName}
正在为你升级 VIP,马上进入支付...
`; document.body.appendChild(node); window.setTimeout(() => { const current = document.getElementById(PAY_LAUNCH_OVERLAY_ID); if (current) current.remove(); }, 2400); } function savePendingOrder(orderId: string): void { const normalizedOrderId = orderId.trim(); if (!normalizedOrderId) return; setPayOrderCookie(normalizedOrderId); setStorage(PAY_ORDER_COOKIE, `${normalizedOrderId}|${Date.now()}`); } function getStoredAnonymousUserId(): string | null { const memosAccount = getStorage("memosAccount"); if (memosAccount && /^user\d+$/.test(memosAccount)) return memosAccount; const userId = getStorage("userId"); if (userId && /^user\d+$/.test(userId)) return userId; const cookieUserId = getCookie(ANONYMOUS_USER_COOKIE); if (cookieUserId && /^user\d+$/.test(cookieUserId)) { setStorage("memosAccount", cookieUserId); return cookieUserId; } return null; } /** 同步获取 userId,用于微信内立即表单提交,避免异步导致手势失效 */ function getOrCreateUserIdSync(): string { const anonymousUserId = getStoredAnonymousUserId(); if (anonymousUserId) { setStorage("userId", anonymousUserId); setAnonymousUserCookie(anonymousUserId); return anonymousUserId; } let userId = getStorage("userId"); if (userId?.startsWith("{") && userId.includes("data")) { try { const parsed = JSON.parse(userId) as { data?: string }; if (parsed?.data) { userId = parsed.data; setStorage("userId", userId); } } catch { userId = null; } } if (!userId) { userId = `user${Date.now()}`; setStorage("userId", userId); } return userId; } async function getOrCreateUserId(): Promise { const syncId = getOrCreateUserIdSync(); if (syncId && !syncId.startsWith("user")) { return syncId; } try { const meRes = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" }); const meData = await meRes.json(); if (meData?.user?.id) { setStorage("userId", meData.user.id); return meData.user.id; } } catch { /* ignore */ } return syncId; } function redirectToPayZpay(params: { user_id: string; wechat_userid?: string; wechat_nick?: string; wechat_avatar?: string; return_url: string; total_fee: number; type: string; channel: "alipay" | "wxpay"; device: "pc" | "h5" | "wechat"; }) { const form = document.createElement("form"); form.method = "POST"; form.action = "/api/pay"; form.target = "_self"; form.style.display = "none"; const fields: [string, string][] = [ ["user_id", params.user_id], ...(params.wechat_userid ? [["wechat_userid", params.wechat_userid] as [string, string]] : []), ...(params.wechat_nick ? [["wechat_nick", params.wechat_nick] as [string, string]] : []), ...(params.wechat_avatar ? [["wechat_avatar", params.wechat_avatar] as [string, string]] : []), ["return_url", params.return_url], ["total_fee", String(params.total_fee)], ["type", params.type], ["channel", params.channel], ["device", params.device], ["provider", "zpay"], ["html", "1"], ]; fields.forEach(([k, v]) => { const input = document.createElement("input"); input.type = "hidden"; input.name = k; input.value = v; form.appendChild(input); }); document.body.appendChild(form); form.submit(); } async function redirectToPayZpayH5( params: { user_id: string; wechat_userid?: string; wechat_nick?: string; wechat_avatar?: string; return_url: string; total_fee: number; type: string; channel: "alipay" | "wxpay"; }, onPendingOrder?: (orderId: string) => void ): Promise { const res = await fetch("/api/pay", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...params, device: "h5", provider: "zpay", html: "0", }), }); const data = await res.json().catch(() => ({})); if (!data?.ok) { alert(data?.error || "支付发起失败"); return null; } const hasRedirect = !!data?.redirect_url; const hasParams = !!data?.params && !!data?.payUrl; if (!hasRedirect && !hasParams) { alert(data?.error || "支付发起失败"); return null; } const orderId = data?.order_id?.trim() || null; if (orderId) { savePendingOrder(orderId); onPendingOrder?.(orderId); } if (data?.redirect_url) { window.location.href = data.redirect_url; return orderId; } const form = document.createElement("form"); form.method = "POST"; form.action = data.payUrl; form.target = "_self"; form.style.display = "none"; for (const [k, v] of Object.entries(data.params as Record)) { if (v == null) continue; const input = document.createElement("input"); input.type = "hidden"; input.name = k; input.value = String(v); form.appendChild(input); } document.body.appendChild(form); form.submit(); document.body.removeChild(form); return orderId; } /** 微信内:同步表单 POST 到 /api/pay,避免异步 fetch 后 form.submit 被微信拦截 */ function payWechatXorpaySync(params: { user_id: string; wechat_userid?: string; wechat_nick?: string; wechat_avatar?: string; return_url: string; total_fee: number; type: string; channel: "alipay" | "wxpay"; }) { const form = document.createElement("form"); form.method = "POST"; form.action = "/api/pay"; form.target = "_self"; form.style.display = "none"; const fields: [string, string][] = [ ["user_id", params.user_id], ...(params.wechat_userid ? [["wechat_userid", params.wechat_userid] as [string, string]] : []), ...(params.wechat_nick ? [["wechat_nick", params.wechat_nick] as [string, string]] : []), ...(params.wechat_avatar ? [["wechat_avatar", params.wechat_avatar] as [string, string]] : []), ["return_url", params.return_url], ["total_fee", String(params.total_fee)], ["type", params.type], ["channel", params.channel], ["device", "wechat"], ["provider", "xorpay"], ["html", "1"], ]; fields.forEach(([k, v]) => { const input = document.createElement("input"); input.type = "hidden"; input.name = k; input.value = v; form.appendChild(input); }); document.body.appendChild(form); form.submit(); } /** * 触发支付 - 与首页「立即加入」效果一致,直接发起支付流程 * @param onPendingOrder 可选,H5 支付时收到 order_id 的回调(用于轮询) */ export async function triggerPay( onPendingOrder?: (orderId: string) => void ): Promise { const userId = typeof navigator !== "undefined" && /MicroMessenger/i.test(navigator.userAgent) ? getOrCreateUserIdSync() : await getOrCreateUserId(); if (/^user\d+$/.test(userId)) { setStorage("memosAccount", userId); setStorage("userId", userId); setAnonymousUserCookie(userId); } const returnUrl = `${window.location.origin}/paid`; const env = getPayEnv(); const device = getDeviceFromEnv(env); const wechatUserId = (getStorage("wechatUserId") || "").trim() || (getStorage("userid") || "").trim(); const isPayTestMode = getStorage(VIP_PAY_TEST_MODE_KEY) === "1"; const totalFee = isPayTestMode ? 100 : getPaymentConfig().defaultAmount ?? 8800; const profile = getDisplayProfile(); const shouldShowLaunchOverlay = device !== "wechat" && !!(profile.avatar || "").trim(); if (shouldShowLaunchOverlay) { showPayLaunchOverlay(profile.displayName, profile.avatar); } const base = { user_id: userId, wechat_userid: wechatUserId || undefined, wechat_nick: profile.displayName, wechat_avatar: profile.avatar || undefined, return_url: returnUrl, total_fee: totalFee, type: "vip", channel: "wxpay" as const, }; if (device === "wechat") { payWechatXorpaySync(base); } else if (device === "h5") { await redirectToPayZpayH5(base, onPendingOrder); } else { redirectToPayZpay({ ...base, device }); } }