458 lines
13 KiB
TypeScript
458 lines
13 KiB
TypeScript
/**
|
||
* 微信支付集成工具类
|
||
* 专门处理微信支付相关功能
|
||
*/
|
||
import { paymentApi } from '@/api';
|
||
import type { CreateOrderParams } from '@/api/types';
|
||
import { showToast } from './uni-compat';
|
||
|
||
declare const uni: any;
|
||
|
||
const PENDING_PAYMENT_KEY = 'wx_pending_payment';
|
||
|
||
export interface PendingPaymentState {
|
||
activeTab?: string;
|
||
reportId?: number;
|
||
unlockType?: string;
|
||
price?: number;
|
||
timestamp: number;
|
||
}
|
||
|
||
export const savePendingPayment = (state: Omit<PendingPaymentState, 'timestamp'>): void => {
|
||
try {
|
||
uni.setStorageSync(PENDING_PAYMENT_KEY, JSON.stringify({ ...state, timestamp: Date.now() }));
|
||
} catch {}
|
||
};
|
||
|
||
export const getPendingPayment = (): PendingPaymentState | null => {
|
||
try {
|
||
const raw = uni.getStorageSync(PENDING_PAYMENT_KEY);
|
||
if (!raw) return null;
|
||
const state = JSON.parse(raw) as PendingPaymentState;
|
||
if (Date.now() - state.timestamp > 5 * 60 * 1000) {
|
||
uni.removeStorageSync(PENDING_PAYMENT_KEY);
|
||
return null;
|
||
}
|
||
return state;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
export const clearPendingPayment = (): void => {
|
||
try { uni.removeStorageSync(PENDING_PAYMENT_KEY); } catch {}
|
||
};
|
||
|
||
export const cleanH5WechatCodeFromUrl = (): void => {
|
||
try {
|
||
if (typeof window === 'undefined') return;
|
||
const url = new URL(window.location.href);
|
||
if (url.searchParams.has('code')) {
|
||
url.searchParams.delete('code');
|
||
url.searchParams.delete('state');
|
||
window.history.replaceState(null, '', url.toString());
|
||
}
|
||
if (url.hash && url.hash.includes('code=')) {
|
||
const [hashPath, hashQuery] = url.hash.split('?');
|
||
if (hashQuery) {
|
||
const hp = new URLSearchParams(hashQuery);
|
||
hp.delete('code');
|
||
hp.delete('state');
|
||
const newHash = hp.toString() ? `${hashPath}?${hp.toString()}` : hashPath;
|
||
window.history.replaceState(null, '', `${url.origin}${url.pathname}${url.search}${newHash}`);
|
||
}
|
||
}
|
||
} catch {}
|
||
};
|
||
|
||
export const isH5WechatBrowser = (): boolean => {
|
||
try {
|
||
if (typeof window === 'undefined') return false;
|
||
const ua = window.navigator?.userAgent || '';
|
||
return /MicroMessenger/i.test(ua);
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
const getH5UrlParam = (name: string): string | undefined => {
|
||
try {
|
||
if (typeof window === 'undefined') return undefined;
|
||
let search = window.location.search || '';
|
||
|
||
if (!search && window.location.hash && window.location.hash.includes('?')) {
|
||
const hashQuery = window.location.hash.split('?')[1] || '';
|
||
search = hashQuery ? `?${hashQuery}` : '';
|
||
}
|
||
|
||
if (!search) return undefined;
|
||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
||
const value = params.get(name) || '';
|
||
return value || undefined;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
};
|
||
|
||
export const getH5WechatCode = (): string | undefined => {
|
||
try {
|
||
if (!isH5WechatBrowser()) return undefined;
|
||
return getH5UrlParam('code');
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
};
|
||
|
||
|
||
|
||
export const redirectToH5WechatOAuthForCode = (): void => {
|
||
if (typeof window === 'undefined') return;
|
||
if (!isH5WechatBrowser()) return;
|
||
|
||
const existingCode = getH5WechatCode();
|
||
if (existingCode) return;
|
||
|
||
const host = window.location.hostname;
|
||
if (host === 'localhost' || host === '127.0.0.1' || /^192\.168\./.test(host) || /^10\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host)) {
|
||
uni.showToast({ title: '本地环境无法微信授权,请使用线上域名', icon: 'none' });
|
||
return;
|
||
}
|
||
|
||
const appId = 'wx1ca1ac7ad12123ac';
|
||
if (!appId) {
|
||
uni.showToast({ title: '缺少VITE_WECHAT_H5_APPID', icon: 'none' });
|
||
return;
|
||
}
|
||
|
||
const href = window.location.href;
|
||
const cleanUrl = new URL(href);
|
||
cleanUrl.searchParams.delete('code');
|
||
cleanUrl.searchParams.delete('state');
|
||
const redirectUri = encodeURIComponent(cleanUrl.toString());
|
||
const scope = 'snsapi_base';
|
||
const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&state=STATE#wechat_redirect`;
|
||
window.location.replace(url);
|
||
};
|
||
|
||
/**
|
||
* 支付状态枚举
|
||
*/
|
||
export enum PaymentStatus {
|
||
PENDING = 'pending',
|
||
PAID = 'paid',
|
||
CANCELLED = 'cancelled',
|
||
FAILED = 'failed',
|
||
REFUNDED = 'refunded'
|
||
}
|
||
|
||
/**
|
||
* 支付结果接口
|
||
*/
|
||
export interface PaymentResult {
|
||
success: boolean;
|
||
outTradeNo?: string;
|
||
msg?: string;
|
||
status?: PaymentStatus;
|
||
}
|
||
|
||
/**
|
||
* 微信支付配置
|
||
*/
|
||
export interface WechatPayConfig {
|
||
enablePolling?: boolean; // 是否启用轮询查询
|
||
maxPollAttempts?: number; // 最大轮询次数
|
||
pollInterval?: number; // 轮询间隔(毫秒)
|
||
}
|
||
|
||
/**
|
||
* 发起微信支付(完整流程)
|
||
* @param params 订单参数
|
||
* @param config 支付配置
|
||
* @returns Promise<支付结果>
|
||
*/
|
||
export const initiateWechatPayment = async (
|
||
params: CreateOrderParams,
|
||
config: WechatPayConfig = {}
|
||
): Promise<PaymentResult> => {
|
||
const {
|
||
enablePolling = true,
|
||
maxPollAttempts = 10,
|
||
pollInterval = 2000
|
||
} = config;
|
||
|
||
try {
|
||
// 1. 创建支付订单
|
||
const orderResult = await createPaymentOrder(params);
|
||
if (!orderResult.success) {
|
||
return orderResult;
|
||
}
|
||
|
||
// 2. 调起微信支付
|
||
const paymentResult = await callWechatPay(orderResult.orderData!);
|
||
|
||
// 3. 如果支付成功且启用轮询,则验证支付状态
|
||
if (paymentResult.success && enablePolling && paymentResult.outTradeNo) {
|
||
const verified = await verifyPaymentStatus(
|
||
paymentResult.outTradeNo,
|
||
maxPollAttempts,
|
||
pollInterval
|
||
);
|
||
|
||
if (!verified) {
|
||
return {
|
||
success: false,
|
||
outTradeNo: paymentResult.outTradeNo,
|
||
msg: '支付状态验证失败,请联系客服',
|
||
status: PaymentStatus.FAILED
|
||
};
|
||
}
|
||
}
|
||
|
||
return paymentResult;
|
||
} catch (error: any) {
|
||
console.error('微信支付流程失败:', error);
|
||
return {
|
||
success: false,
|
||
msg: error.msg || error.message || '支付失败,请重试',
|
||
status: PaymentStatus.FAILED
|
||
};
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 创建支付订单
|
||
* @param params 订单参数
|
||
* @returns Promise<订单创建结果>
|
||
*/
|
||
const createPaymentOrder = async (params: CreateOrderParams): Promise<{
|
||
success: boolean;
|
||
orderData?: any;
|
||
msg?: string;
|
||
}> => {
|
||
try {
|
||
uni.showLoading({ title: '创建订单中...' });
|
||
|
||
// 确保支付参数完整
|
||
const h5Code = params.code || getH5WechatCode();
|
||
if (isH5WechatBrowser() && !h5Code) {
|
||
uni.hideLoading();
|
||
redirectToH5WechatOAuthForCode();
|
||
return {
|
||
success: false,
|
||
msg: '微信授权中',
|
||
};
|
||
}
|
||
|
||
const paymentParams: CreateOrderParams = {
|
||
...params,
|
||
pay_type: 'jsapi',
|
||
code: h5Code,
|
||
};
|
||
|
||
const orderRes = await paymentApi.createOrder(paymentParams);
|
||
uni.hideLoading();
|
||
|
||
cleanH5WechatCodeFromUrl();
|
||
|
||
if (!orderRes.appId && !orderRes.payment_params) {
|
||
throw new Error('获取支付参数失败');
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
orderData: orderRes
|
||
};
|
||
} catch (error: any) {
|
||
uni.hideLoading();
|
||
console.error('创建订单失败:', error);
|
||
|
||
// invalid code 时清理状态,避免无限循环
|
||
const errorMsg = error.msg || error.message || '创建订单失败';
|
||
if (errorMsg.includes('invalid code') || errorMsg.includes('code')) {
|
||
cleanH5WechatCodeFromUrl();
|
||
clearPendingPayment();
|
||
}
|
||
|
||
showToast({ title: errorMsg, icon: 'none' });
|
||
|
||
return {
|
||
success: false,
|
||
msg: errorMsg
|
||
};
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 调起微信支付(H5使用WeixinJSBridge,小程序使用uni.requestPayment)
|
||
* @param orderData 订单数据
|
||
* @returns Promise<支付结果>
|
||
*/
|
||
const callWechatPay = async (orderData: any): Promise<PaymentResult> => {
|
||
// 支持平铺和嵌套两种格式
|
||
const pp = orderData.payment_params || orderData;
|
||
if (!pp?.paySign) {
|
||
return { success: false, msg: '缺少支付参数', status: PaymentStatus.FAILED };
|
||
}
|
||
|
||
// H5微信浏览器使用 WeixinJSBridge
|
||
if (isH5WechatBrowser() && typeof window !== 'undefined') {
|
||
return callWechatPayH5(orderData);
|
||
}
|
||
|
||
// 小程序环境使用 uni.requestPayment
|
||
return new Promise((resolve) => {
|
||
uni.requestPayment({
|
||
provider: 'wxpay',
|
||
timeStamp: pp.timeStamp,
|
||
nonceStr: pp.nonceStr,
|
||
package: pp.package,
|
||
signType: pp.signType || 'MD5',
|
||
paySign: pp.paySign,
|
||
success: () => {
|
||
showToast({ title: '支付成功', icon: 'success' });
|
||
resolve({ success: true, outTradeNo: orderData.out_trade_no, msg: '支付成功', status: PaymentStatus.PAID });
|
||
},
|
||
fail: (err: any) => {
|
||
let msg = '支付失败';
|
||
let status = PaymentStatus.FAILED;
|
||
if (err.errMsg?.includes('cancel')) { msg = '用户取消支付'; status = PaymentStatus.CANCELLED; }
|
||
if (status !== PaymentStatus.CANCELLED) showToast({ title: msg, icon: 'none' });
|
||
resolve({ success: false, outTradeNo: orderData.out_trade_no, msg, status });
|
||
}
|
||
});
|
||
});
|
||
};
|
||
|
||
/**
|
||
* H5微信浏览器内调起JSAPI支付
|
||
*/
|
||
const callWechatPayH5 = (orderData: any): Promise<PaymentResult> => {
|
||
const pp = orderData.payment_params || orderData;
|
||
return new Promise((resolve) => {
|
||
const invoke = () => {
|
||
(window as any).WeixinJSBridge.invoke(
|
||
'getBrandWCPayRequest',
|
||
{
|
||
appId: pp.appId,
|
||
timeStamp: pp.timeStamp,
|
||
nonceStr: pp.nonceStr,
|
||
package: pp.package,
|
||
signType: pp.signType || 'RSA',
|
||
paySign: pp.paySign,
|
||
},
|
||
(res: any) => {
|
||
if (res.err_msg === 'get_brand_wcpay_request:ok') {
|
||
showToast({ title: '支付成功', icon: 'success' });
|
||
resolve({ success: true, outTradeNo: orderData.out_trade_no, msg: '支付成功', status: PaymentStatus.PAID });
|
||
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
|
||
resolve({ success: false, outTradeNo: orderData.out_trade_no, msg: '用户取消支付', status: PaymentStatus.CANCELLED });
|
||
} else {
|
||
showToast({ title: '支付失败', icon: 'none' });
|
||
resolve({ success: false, outTradeNo: orderData.out_trade_no, msg: res.err_msg || '支付失败', status: PaymentStatus.FAILED });
|
||
}
|
||
}
|
||
);
|
||
};
|
||
|
||
if (typeof (window as any).WeixinJSBridge === 'undefined') {
|
||
document.addEventListener('WeixinJSBridgeReady', invoke, false);
|
||
} else {
|
||
invoke();
|
||
}
|
||
});
|
||
};
|
||
|
||
/**
|
||
* 验证支付状态(轮询查询)
|
||
* @param outTradeNo 商户订单号
|
||
* @param maxAttempts 最大尝试次数
|
||
* @param interval 查询间隔(毫秒)
|
||
* @returns Promise<是否支付成功>
|
||
*/
|
||
const verifyPaymentStatus = async (
|
||
outTradeNo: string,
|
||
maxAttempts: number = 10,
|
||
interval: number = 2000
|
||
): Promise<boolean> => {
|
||
for (let i = 0; i < maxAttempts; i++) {
|
||
try {
|
||
const res = await paymentApi.queryOrder(outTradeNo);
|
||
|
||
if (res.status === 'paid') {
|
||
return true;
|
||
} else if (res.status === 'cancelled' || res.status === 'refunded') {
|
||
return false;
|
||
}
|
||
|
||
// 等待后继续查询
|
||
await new Promise(resolve => setTimeout(resolve, interval));
|
||
} catch (error) {
|
||
console.error(`第${i + 1}次查询订单状态失败:`, error);
|
||
|
||
if (i === maxAttempts - 1) {
|
||
throw error;
|
||
}
|
||
|
||
await new Promise(resolve => setTimeout(resolve, interval));
|
||
}
|
||
}
|
||
|
||
return false;
|
||
};
|
||
|
||
/**
|
||
* 快速支付(简化版本,适用于大多数场景)
|
||
* @param params 订单参数
|
||
* @returns Promise<支付结果>
|
||
*/
|
||
export const quickWechatPay = async (params: CreateOrderParams): Promise<PaymentResult> => {
|
||
return initiateWechatPayment(params, {
|
||
enablePolling: false // 不启用轮询,支付成功后立即返回
|
||
});
|
||
};
|
||
|
||
/**
|
||
* 安全支付(完整验证版本,适用于重要交易)
|
||
* @param params 订单参数
|
||
* @returns Promise<支付结果>
|
||
*/
|
||
export const secureWechatPay = async (params: CreateOrderParams): Promise<PaymentResult> => {
|
||
return initiateWechatPayment(params, {
|
||
enablePolling: true,
|
||
maxPollAttempts: 15,
|
||
pollInterval: 1500
|
||
});
|
||
};
|
||
|
||
/**
|
||
* 处理支付结果的通用方法
|
||
* @param result 支付结果
|
||
* @param onSuccess 成功回调
|
||
* @param onFail 失败回调
|
||
* @param onCancel 取消回调
|
||
*/
|
||
export const handlePaymentResult = (
|
||
result: PaymentResult,
|
||
callbacks: {
|
||
onSuccess?: (result: PaymentResult) => void;
|
||
onFail?: (result: PaymentResult) => void;
|
||
onCancel?: (result: PaymentResult) => void;
|
||
}
|
||
) => {
|
||
const { onSuccess, onFail, onCancel } = callbacks;
|
||
|
||
if (result.success) {
|
||
onSuccess?.(result);
|
||
} else {
|
||
switch (result.status) {
|
||
case PaymentStatus.CANCELLED:
|
||
onCancel?.(result);
|
||
break;
|
||
case PaymentStatus.FAILED:
|
||
default:
|
||
onFail?.(result);
|
||
break;
|
||
}
|
||
}
|
||
};
|