Files
template-10584/src/utils/payment.ts
赵忠林 94e1e05fdf fix(payment): 修复微信支付时openid绑定问题
- 新增确保支付前openid正确绑定的方法,解决支付账号不一致问题
- 在创建微信支付订单前强制刷新openid,防止旧微信账号导致支付失败
- 在自动登录后补充openid绑定步骤,确保支付所需的openid存在
- 设计为非阻塞流程,避免网络异常导致支付阻塞
- 仅针对微信支付触发,其他支付方式不受影响
2026-04-25 12:59:59 +08:00

605 lines
19 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.

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
};
}