Files
gitlab-instance-0a899031_no…/app/lib/triggerPay.ts
2026-04-01 02:23:17 -05:00

344 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 共享支付触发逻辑 - 与首页「立即加入」效果一致
* 供首页、课程页、云手机页等所有「立即报名」按钮使用,直接发起支付,不跳转首页
*
* 校验逻辑:
* - 页面有本地存储(登录资料 + 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] || c);
const safeAvatar = (avatar || "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 = `
<div style="display:flex;align-items:center;gap:10px;padding:12px 14px;border-radius:12px;background:#0f172a;color:#e2e8f0;box-shadow:0 10px 30px rgba(2,6,23,.35);max-width:90vw;">
<img src="${safeAvatar}" alt="${safeName}" style="width:30px;height:30px;border-radius:9999px;object-fit:cover;border:1px solid rgba(148,163,184,.5);" />
<div style="font-size:13px;line-height:1.35;">
<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:56vw;">开通对象:${safeName}</div>
<div style="opacity:.88;">正在为你升级 VIP马上进入支付...</div>
</div>
</div>
`;
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<string> {
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<string | null> {
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<string, string>)) {
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<void> {
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 });
}
}