forked from gxwebsoft/mp-10550
- 新增确保支付前openid正确绑定的方法,解决支付账号不一致问题 - 在创建微信支付订单前强制刷新openid,防止旧微信账号导致支付失败 - 在自动登录后补充openid绑定步骤,确保支付所需的openid存在 - 设计为非阻塞流程,避免网络异常导致支付阻塞 - 仅针对微信支付触发,其他支付方式不受影响
605 lines
19 KiB
TypeScript
605 lines
19 KiB
TypeScript
import Taro from '@tarojs/taro';
|
||
import { createOrder, WxPayResult } from '@/api/shop/shopOrder';
|
||
import { OrderCreateRequest } from '@/api/shop/shopOrder/model';
|
||
import { getSelectedStoreFromStorage, getSelectedStoreIdFromStorage } from '@/utils/storeSelection';
|
||
import type { ShopStoreRider } from '@/api/shop/shopStoreRider/model';
|
||
import type { ShopStoreWarehouse } from '@/api/shop/shopStoreWarehouse/model';
|
||
import request from '@/utils/request';
|
||
import { getWxOpenId, getUserInfo } from '@/api/layout';
|
||
|
||
/**
|
||
* 支付类型枚举
|
||
*/
|
||
export enum PaymentType {
|
||
BALANCE = 0, // 余额支付
|
||
WECHAT = 1, // 微信支付
|
||
ALIPAY = 3, // 支付宝支付
|
||
}
|
||
|
||
/**
|
||
* 支付结果回调
|
||
*/
|
||
export interface PaymentCallback {
|
||
// Return `false` to skip default "支付成功" toast + redirect.
|
||
onSuccess?: () => void | boolean | Promise<void | boolean>;
|
||
onError?: (error: string) => void;
|
||
onComplete?: () => void;
|
||
}
|
||
|
||
/**
|
||
* 统一支付处理类
|
||
*/
|
||
export class PaymentHandler {
|
||
// 简单缓存,避免频繁请求(小程序单次运行生命周期内有效)
|
||
private static storeRidersCache = new Map<number, ShopStoreRider[]>();
|
||
private static warehousesCache: ShopStoreWarehouse[] | null = null;
|
||
|
||
/**
|
||
* 【关键修复】支付前确保当前微信用户的 openid 已正确绑定到后端
|
||
*
|
||
* 问题场景:
|
||
* 1. 用户通过手机号注册 → openid 未绑定 → 后端用空 openid 创建预支付单 → 支付时报"账号不一致"
|
||
* 2. 用户切换了微信账号 → 本地缓存的用户信息是旧账号的 → openid 与当前支付人不匹配
|
||
* 3. 自动登录(loginByOpenId)未触发 getWxOpenId 绑定流程
|
||
*
|
||
* 解决方案:每次微信支付前,重新获取 wx.login code 并调用 getWxOpenId 绑定,
|
||
* 确保后端记录的 openid = 当前实际支付人的 openid
|
||
*/
|
||
private static async ensureOpenIdBeforePay(): Promise<void> {
|
||
try {
|
||
// 非微信环境跳过(如 H5 开发调试)
|
||
let isWeapp = false;
|
||
try {
|
||
isWeapp = Taro.getEnv() === Taro.ENV_TYPE.WEAPP;
|
||
} catch (_e) {
|
||
isWeapp = process.env.TARO_ENV === 'weapp';
|
||
}
|
||
if (!isWeapp) return;
|
||
|
||
// 获取当前登录用户的最新信息(检查是否已有 openid)
|
||
let currentUser = null;
|
||
try {
|
||
currentUser = await getUserInfo();
|
||
} catch (_e) {
|
||
// getUserInfo 失败时不阻塞支付(可能是 token 过期等),让后续接口自行报错
|
||
console.warn('[ensureOpenId] 获取用户信息失败,跳过 openid 校验');
|
||
return;
|
||
}
|
||
|
||
// 如果用户已有 openid,仍然需要刷新:因为用户可能切换了微信账号
|
||
// 每次都重新获取 code + 绑定,确保 openid 是当前微信会话的
|
||
|
||
const code = await new Promise<string | undefined>((resolve, reject) => {
|
||
Taro.login({
|
||
success: (res) => resolve(res.code as string),
|
||
fail: (e) => reject(e),
|
||
timeout: 5000, // 5秒超时
|
||
});
|
||
});
|
||
|
||
if (!code) {
|
||
console.warn('[ensureOpenId] wx.login 未返回 code');
|
||
return;
|
||
}
|
||
|
||
// 调用后端绑定/更新 openid
|
||
await getWxOpenId({ code });
|
||
console.log('[ensureOpenId] openid 刷新/绑定成功');
|
||
|
||
// 同步本地 User 缓存,确保后续逻辑能读到最新 openid
|
||
try {
|
||
const freshUser = await getUserInfo();
|
||
if (freshUser) {
|
||
Taro.setStorageSync('User', freshUser);
|
||
}
|
||
} catch (_e) {
|
||
// ignore: 服务端已更新 openid,本地缓存不同步也不影响本次支付
|
||
}
|
||
} catch (error) {
|
||
// openid 刷新失败不阻塞支付流程(可能网络波动),
|
||
// 但记录日志方便排查;若确实不一致,微信支付侧会报错
|
||
console.warn('[ensureOpenId] openid 刷新失败(非阻塞):', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 执行支付
|
||
* @param orderData 订单数据
|
||
* @param paymentType 支付类型
|
||
* @param callback 回调函数
|
||
*/
|
||
static async pay(
|
||
orderData: OrderCreateRequest,
|
||
paymentType: PaymentType,
|
||
callback?: PaymentCallback
|
||
): Promise<void> {
|
||
Taro.showLoading({ title: '支付中...' });
|
||
|
||
try {
|
||
// 若调用方未指定门店,则自动注入“已选门店”,用于订单门店归属/统计。
|
||
if (orderData.storeId === undefined || orderData.storeId === null) {
|
||
const storeId = getSelectedStoreIdFromStorage();
|
||
if (storeId) {
|
||
orderData.storeId = storeId;
|
||
}
|
||
}
|
||
if (!orderData.storeName) {
|
||
const store = getSelectedStoreFromStorage();
|
||
if (store?.name) {
|
||
orderData.storeName = store.name;
|
||
}
|
||
}
|
||
|
||
// 自动派单:按门店骑手优先级(dispatchPriority)选择 riderId(不覆盖手动指定)
|
||
if ((orderData.riderId === undefined || orderData.riderId === null) && orderData.storeId) {
|
||
const riderUserId = await this.pickRiderUserIdForStore(orderData.storeId);
|
||
if (riderUserId) {
|
||
orderData.riderId = riderUserId;
|
||
}
|
||
}
|
||
|
||
// 仓库选择:若未指定 warehouseId,则按“离门店最近”兜底选择一个(不覆盖手动指定)
|
||
if ((orderData.warehouseId === undefined || orderData.warehouseId === null) && orderData.storeId) {
|
||
const warehouseId = await this.pickWarehouseIdForStore(orderData.storeId);
|
||
if (warehouseId) {
|
||
orderData.warehouseId = warehouseId;
|
||
}
|
||
}
|
||
|
||
// 设置支付类型
|
||
orderData.payType = paymentType;
|
||
|
||
// 【关键修复】微信支付前,强制刷新/绑定当前微信用户的 openid
|
||
// 防止"下单账号与支付账号不一致"错误
|
||
if (paymentType === PaymentType.WECHAT) {
|
||
await this.ensureOpenIdBeforePay();
|
||
}
|
||
|
||
console.log('创建订单请求:', orderData);
|
||
|
||
// 创建订单
|
||
const result = await createOrder(orderData);
|
||
|
||
console.log('订单创建结果:', result);
|
||
|
||
if (!result) {
|
||
throw new Error('创建订单失败');
|
||
}
|
||
|
||
// 验证订单创建结果
|
||
if (!result.orderNo) {
|
||
throw new Error('订单号获取失败');
|
||
}
|
||
|
||
let paymentSuccess = false;
|
||
|
||
// 根据支付类型处理
|
||
switch (paymentType) {
|
||
case PaymentType.WECHAT:
|
||
await this.handleWechatPay(result);
|
||
paymentSuccess = true;
|
||
break;
|
||
case PaymentType.BALANCE:
|
||
paymentSuccess = await this.handleBalancePay(result);
|
||
break;
|
||
case PaymentType.ALIPAY:
|
||
await this.handleAlipay(result);
|
||
paymentSuccess = true;
|
||
break;
|
||
default:
|
||
throw new Error('不支持的支付方式');
|
||
}
|
||
|
||
// 只有确认支付成功才显示成功提示和跳转
|
||
if (paymentSuccess) {
|
||
console.log('支付成功,订单号:', result.orderNo);
|
||
|
||
// 先收起 loading,避免遮挡 modal/toast
|
||
try {
|
||
Taro.hideLoading();
|
||
} catch (_e) {
|
||
// ignore
|
||
}
|
||
|
||
const onSuccessResult = await callback?.onSuccess?.();
|
||
const skipDefaultSuccessBehavior = onSuccessResult === false;
|
||
|
||
if (!skipDefaultSuccessBehavior) {
|
||
Taro.showToast({
|
||
title: '支付成功',
|
||
icon: 'success'
|
||
});
|
||
|
||
// 跳转到订单页面
|
||
setTimeout(() => {
|
||
Taro.navigateTo({ url: '/user/order/order' });
|
||
}, 2000);
|
||
}
|
||
} else {
|
||
throw new Error('支付未完成');
|
||
}
|
||
|
||
} catch (error: any) {
|
||
console.error('支付失败:', error);
|
||
|
||
// 获取详细错误信息
|
||
const errorMessage = this.getErrorMessage(error);
|
||
|
||
Taro.showToast({
|
||
title: errorMessage,
|
||
icon: 'error'
|
||
});
|
||
|
||
// 标记错误已处理,避免上层重复处理
|
||
error.handled = true;
|
||
callback?.onError?.(errorMessage);
|
||
|
||
// 重新抛出错误,让上层知道支付失败
|
||
throw error;
|
||
} finally {
|
||
Taro.hideLoading();
|
||
callback?.onComplete?.();
|
||
}
|
||
}
|
||
|
||
private static parseLngLat(raw: string | undefined): { lng: number; lat: number } | null {
|
||
const text = (raw || '').trim();
|
||
if (!text) return null;
|
||
const parts = text.split(/[,\s]+/).filter(Boolean);
|
||
if (parts.length < 2) return null;
|
||
const a = parseFloat(parts[0]);
|
||
const b = parseFloat(parts[1]);
|
||
if (Number.isNaN(a) || Number.isNaN(b)) return null;
|
||
const looksLikeLngLat = Math.abs(a) <= 180 && Math.abs(b) <= 90;
|
||
const looksLikeLatLng = Math.abs(a) <= 90 && Math.abs(b) <= 180;
|
||
if (looksLikeLngLat) return { lng: a, lat: b };
|
||
if (looksLikeLatLng) return { lng: b, lat: a };
|
||
return null;
|
||
}
|
||
|
||
private static distanceMeters(a: { lng: number; lat: number }, b: { lng: number; lat: number }) {
|
||
const toRad = (x: number) => (x * Math.PI) / 180;
|
||
const R = 6371000;
|
||
const dLat = toRad(b.lat - a.lat);
|
||
const dLng = toRad(b.lng - a.lng);
|
||
const lat1 = toRad(a.lat);
|
||
const lat2 = toRad(b.lat);
|
||
const sin1 = Math.sin(dLat / 2);
|
||
const sin2 = Math.sin(dLng / 2);
|
||
const h = sin1 * sin1 + Math.cos(lat1) * Math.cos(lat2) * sin2 * sin2;
|
||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
|
||
}
|
||
|
||
private static async getRidersForStore(storeId: number): Promise<ShopStoreRider[]> {
|
||
const cached = this.storeRidersCache.get(storeId);
|
||
if (cached) return cached;
|
||
|
||
// 后端字段可能叫 dealerId 或 storeId,这里都带上,服务端忽略未知字段即可。
|
||
// 这里做一次路径兼容(camel vs kebab),避免接口路径不一致导致整单失败。
|
||
const list = await this.listByCompatEndpoint<ShopStoreRider>(
|
||
['/shop/shop-store-rider'],
|
||
{
|
||
storeId: storeId,
|
||
status: 1
|
||
}
|
||
);
|
||
|
||
const usable = (list || []).filter(r => r?.isDelete !== 1 && (r.status === undefined || r.status === 1));
|
||
this.storeRidersCache.set(storeId, usable);
|
||
return usable;
|
||
}
|
||
|
||
private static async pickRiderUserIdForStore(storeId: number): Promise<number | undefined> {
|
||
const riders = await this.getRidersForStore(storeId);
|
||
if (!riders.length) return undefined;
|
||
|
||
// 优先:启用 + 在线 + 自动派单,再按 dispatchPriority 由高到低
|
||
const score = (r: ShopStoreRider) => {
|
||
const enabled = (r.status === undefined || r.status === 1) ? 1 : 0;
|
||
const online = r.workStatus === 1 ? 1 : 0;
|
||
const auto = r.autoDispatchEnabled === 1 ? 1 : 0;
|
||
const p = typeof r.dispatchPriority === 'number' ? r.dispatchPriority : 0;
|
||
return enabled * 1000 + online * 100 + auto * 10 + p;
|
||
};
|
||
|
||
const sorted = [...riders].sort((a, b) => score(b) - score(a));
|
||
return sorted[0]?.userId;
|
||
}
|
||
|
||
private static async getWarehouses(): Promise<ShopStoreWarehouse[]> {
|
||
if (this.warehousesCache) return this.warehousesCache;
|
||
const list = await this.listByCompatEndpoint<ShopStoreWarehouse>(
|
||
['/shop/shop-store-warehouse'],
|
||
{}
|
||
);
|
||
const usable = (list || []).filter(w => w?.isDelete !== 1 && (w.status === undefined || w.status === 1));
|
||
this.warehousesCache = usable;
|
||
return usable;
|
||
}
|
||
|
||
private static async pickWarehouseIdForStore(storeId: number): Promise<number | undefined> {
|
||
const store = getSelectedStoreFromStorage();
|
||
if (!store?.id || store.id !== storeId) return undefined;
|
||
|
||
// 一门店一默认仓库:优先使用门店自带的 warehouseId
|
||
if (store.warehouseId) return store.warehouseId;
|
||
|
||
const storeCoords = this.parseLngLat(store.lngAndLat || store.location);
|
||
if (!storeCoords) return undefined;
|
||
|
||
const warehouses = await this.getWarehouses();
|
||
if (!warehouses.length) return undefined;
|
||
|
||
// 优先选择“门店仓”,否则选最近的任意仓库
|
||
const candidates = warehouses.filter(w => w.type?.includes('门店') || w.type?.includes('门店仓'));
|
||
const list = candidates.length ? candidates : warehouses;
|
||
|
||
const withDistance = list
|
||
.map(w => {
|
||
const coords = this.parseLngLat(w.lngAndLat);
|
||
if (!coords) return { w, d: Number.POSITIVE_INFINITY };
|
||
return { w, d: this.distanceMeters(storeCoords, coords) };
|
||
})
|
||
.sort((a, b) => a.d - b.d);
|
||
|
||
return withDistance[0]?.w?.id;
|
||
}
|
||
|
||
private static async listByCompatEndpoint<T>(
|
||
urls: string[],
|
||
params: Record<string, any>
|
||
): Promise<T[]> {
|
||
for (const url of urls) {
|
||
try {
|
||
const res: any = await (request as any).get(url, params, { showError: false });
|
||
if (res?.code === 0 && Array.isArray(res?.data)) {
|
||
return res.data as T[];
|
||
}
|
||
} catch (_e) {
|
||
// try next
|
||
}
|
||
}
|
||
return [];
|
||
}
|
||
|
||
/**
|
||
* 处理微信支付
|
||
*/
|
||
private static async handleWechatPay(result: WxPayResult): Promise<void> {
|
||
console.log('处理微信支付:', result);
|
||
|
||
if (!result) {
|
||
throw new Error('微信支付参数错误');
|
||
}
|
||
|
||
// 验证微信支付必要参数
|
||
if (!result.timeStamp || !result.nonceStr || !result.package || !result.paySign) {
|
||
throw new Error('微信支付参数不完整');
|
||
}
|
||
|
||
try {
|
||
await Taro.requestPayment({
|
||
timeStamp: result.timeStamp,
|
||
nonceStr: result.nonceStr,
|
||
package: result.package,
|
||
signType: result.signType as any, // 类型转换,因为微信支付的signType是字符串
|
||
paySign: result.paySign,
|
||
});
|
||
|
||
console.log('微信支付成功');
|
||
} catch (payError: any) {
|
||
console.error('微信支付失败:', payError);
|
||
|
||
// 处理微信支付特定错误
|
||
if (payError.errMsg) {
|
||
if (payError.errMsg.includes('cancel')) {
|
||
throw new Error('用户取消支付');
|
||
} else if (payError.errMsg.includes('fail')) {
|
||
throw new Error('微信支付失败,请重试');
|
||
}
|
||
}
|
||
|
||
throw new Error('微信支付失败');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理余额支付
|
||
*/
|
||
private static async handleBalancePay(result: any): Promise<boolean> {
|
||
console.log('处理余额支付:', result);
|
||
|
||
if (!result || !result.orderNo) {
|
||
throw new Error('余额支付参数错误');
|
||
}
|
||
|
||
// 检查支付状态 - 根据后端返回的字段调整
|
||
if (result.payStatus === false || result.payStatus === 0 || result.payStatus === '0') {
|
||
throw new Error('余额不足或支付失败');
|
||
}
|
||
|
||
// 检查订单状态 - 1表示已付款
|
||
if (result.orderStatus !== undefined && result.orderStatus !== 1) {
|
||
throw new Error('订单状态异常,支付可能未成功');
|
||
}
|
||
|
||
// 验证实际扣款金额
|
||
if (result.payPrice !== undefined) {
|
||
const payPrice = parseFloat(result.payPrice);
|
||
if (payPrice <= 0) {
|
||
throw new Error('支付金额异常');
|
||
}
|
||
}
|
||
|
||
// 如果有错误信息字段,检查是否有错误
|
||
if (result.error || result.errorMsg) {
|
||
throw new Error(result.error || result.errorMsg);
|
||
}
|
||
|
||
console.log('余额支付验证通过');
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 处理支付宝支付
|
||
*/
|
||
private static async handleAlipay(_result: any): Promise<void> {
|
||
// 支付宝支付逻辑,根据实际情况实现
|
||
throw new Error('支付宝支付暂未实现');
|
||
}
|
||
|
||
/**
|
||
* 获取详细错误信息
|
||
*/
|
||
private static getErrorMessage(error: any): string {
|
||
if (!error.message) {
|
||
return '支付失败,请重试';
|
||
}
|
||
|
||
const message = error.message;
|
||
|
||
// 配送范围/电子围栏相关错误(优先于“地址信息有误”的兜底)
|
||
if (
|
||
message.includes('不在配送范围') ||
|
||
message.includes('配送范围') ||
|
||
message.includes('电子围栏') ||
|
||
message.includes('围栏')
|
||
) {
|
||
// Toast 文案尽量短(小程序 showToast 标题长度有限),更详细的引导可在业务页面用 Modal 呈现。
|
||
return '暂不支持配送';
|
||
}
|
||
|
||
// 余额相关错误
|
||
if (message.includes('余额不足') || message.includes('balance')) {
|
||
return '账户余额不足,请充值后重试';
|
||
}
|
||
|
||
// 优惠券相关错误
|
||
if (message.includes('优惠券') || message.includes('coupon')) {
|
||
return '优惠券使用失败,请重新选择';
|
||
}
|
||
|
||
// 库存相关错误
|
||
if (message.includes('库存') || message.includes('stock')) {
|
||
return '商品库存不足,请减少购买数量';
|
||
}
|
||
|
||
// 地址相关错误
|
||
if (message.includes('地址') || message.includes('address')) {
|
||
return '收货地址信息有误,请重新选择';
|
||
}
|
||
|
||
// 订单相关错误
|
||
if (message.includes('订单') || message.includes('order')) {
|
||
return '订单创建失败,请重试';
|
||
}
|
||
|
||
// 网络相关错误
|
||
if (message.includes('网络') || message.includes('network') || message.includes('timeout')) {
|
||
return '网络连接异常,请检查网络后重试';
|
||
}
|
||
|
||
// 微信支付相关错误
|
||
if (message.includes('微信') || message.includes('wechat') || message.includes('wx')) {
|
||
return '微信支付失败,请重试';
|
||
}
|
||
|
||
// 返回原始错误信息
|
||
return message;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 快捷支付方法
|
||
*/
|
||
export const quickPay = {
|
||
/**
|
||
* 微信支付
|
||
*/
|
||
wechat: (orderData: OrderCreateRequest, callback?: PaymentCallback) => {
|
||
return PaymentHandler.pay(orderData, PaymentType.WECHAT, callback);
|
||
},
|
||
|
||
/**
|
||
* 余额支付
|
||
*/
|
||
balance: (orderData: OrderCreateRequest, callback?: PaymentCallback) => {
|
||
return PaymentHandler.pay(orderData, PaymentType.BALANCE, callback);
|
||
},
|
||
|
||
/**
|
||
* 支付宝支付
|
||
*/
|
||
alipay: (orderData: OrderCreateRequest, callback?: PaymentCallback) => {
|
||
return PaymentHandler.pay(orderData, PaymentType.ALIPAY, callback);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 构建单商品订单数据
|
||
*/
|
||
export function buildSingleGoodsOrder(
|
||
goodsId: number,
|
||
quantity: number = 1,
|
||
addressId?: number,
|
||
options?: {
|
||
comments?: string;
|
||
deliveryType?: number;
|
||
couponId?: any;
|
||
selfTakeMerchantId?: number;
|
||
skuId?: number;
|
||
specInfo?: string;
|
||
buyerRemarks?: string;
|
||
sendStartTime?: string;
|
||
deliveryMethod?: string;
|
||
deliveryFloor?: number;
|
||
}
|
||
): OrderCreateRequest {
|
||
return {
|
||
goodsItems: [
|
||
{
|
||
goodsId,
|
||
quantity,
|
||
skuId: options?.skuId,
|
||
specInfo: options?.specInfo
|
||
}
|
||
],
|
||
addressId,
|
||
payType: PaymentType.WECHAT, // 默认微信支付,会被PaymentHandler覆盖
|
||
comments: options?.buyerRemarks || options?.comments || '',
|
||
sendStartTime: options?.sendStartTime,
|
||
deliveryType: options?.deliveryType || 0,
|
||
couponId: options?.couponId,
|
||
selfTakeMerchantId: options?.selfTakeMerchantId,
|
||
deliveryMethod: options?.deliveryMethod,
|
||
deliveryFloor: options?.deliveryFloor
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 构建购物车订单数据
|
||
*/
|
||
export function buildCartOrder(
|
||
cartItems: Array<{ goodsId: number; quantity: number }>,
|
||
addressId?: number,
|
||
options?: {
|
||
comments?: string;
|
||
deliveryType?: number;
|
||
couponId?: number;
|
||
selfTakeMerchantId?: number;
|
||
}
|
||
): OrderCreateRequest {
|
||
return {
|
||
goodsItems: cartItems.map(item => ({
|
||
goodsId: item.goodsId,
|
||
quantity: item.quantity
|
||
})),
|
||
addressId,
|
||
payType: PaymentType.WECHAT, // 默认微信支付,会被PaymentHandler覆盖
|
||
comments: options?.comments || '购物车下单',
|
||
deliveryType: options?.deliveryType || 0,
|
||
couponId: options?.couponId,
|
||
selfTakeMerchantId: options?.selfTakeMerchantId
|
||
};
|
||
}
|