310 lines
9.7 KiB
TypeScript
310 lines
9.7 KiB
TypeScript
/**
|
||
* 共享支付触发逻辑 - 与首页「立即加入」效果一致
|
||
* 供首页、课程页、云手机页等所有「立即报名」按钮使用,直接发起支付,不跳转首页
|
||
*
|
||
* 校验逻辑:
|
||
* - 页面有本地存储(登录资料 + 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";
|
||
|
||
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 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;
|
||
}
|
||
|
||
async function recoverAnonymousUserIdByWechatUserId(): Promise<string | null> {
|
||
const wechatUserId =
|
||
(getStorage("wechatUserId") || "").trim() ||
|
||
(getStorage("userid") || "").trim();
|
||
if (!wechatUserId) return null;
|
||
try {
|
||
const res = await fetch("/api/vip/identity-diagnose", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ wechat_userid: wechatUserId }),
|
||
credentials: "same-origin",
|
||
cache: "no-store",
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
const candidate = [
|
||
data?.resolved?.linked_anonymous_user_id,
|
||
data?.vip_email_link?.by_anonymous_user_id?.anonymous_user_id,
|
||
data?.vip_email_link?.by_email?.anonymous_user_id,
|
||
data?.payments?.by_user_id?.user_id,
|
||
data?.payments?.by_linked_user_id?.user_id,
|
||
].find((x) => typeof x === "string" && /^user\d+$/.test(x.trim()));
|
||
return typeof candidate === "string" ? candidate.trim() : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function redirectToPayZpay(params: {
|
||
user_id: 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],
|
||
["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;
|
||
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;
|
||
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],
|
||
["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 recoveredByWechat = await recoverAnonymousUserIdByWechatUserId();
|
||
const userId = recoveredByWechat
|
||
? recoveredByWechat
|
||
: 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 isPayTestMode = getStorage(VIP_PAY_TEST_MODE_KEY) === "1";
|
||
const totalFee = isPayTestMode ? 100 : getPaymentConfig().defaultAmount ?? 8800;
|
||
const base = {
|
||
user_id: userId,
|
||
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 });
|
||
}
|
||
}
|