/** * 微信小程序虚拟支付(道具直购)客户端封装 * * 官方 API:https://developers.weixin.qq.com/miniprogram/dev/api/payment/wx.requestVirtualPayment.html * 接入指引:https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/virtual-payment/person * * 说明: * - signData / paySig / signature 全部由服务端生成,前端只负责透传,绝不自行拼装或签名 * - 支付成功的 success 回调可能丢失(用户异常退出),因此**不能**作为发货依据, * 发货由服务端「发货推送 + 查单兜底」完成,前端只轮询自己服务器的订单状态 * - 这里用运行时能力探测(而非条件编译)来隔离平台差异:H5 / 其他小程序端拿不到 * wx 全局对象时直接给出可读提示,同时避免 vue-tsc 看到重复声明 */ /** 微信侧可用能力的结构化描述(只声明本项目用到的部分) */ interface WxLike { requestVirtualPayment?: (options: Record) => void getSystemInfoSync?: () => { platform?: string, version?: string } } export interface VirtualPayParams { signData: string paySig: string signature: string mode: string } export interface PaySupport { ok: boolean reason?: string } /** 虚拟支付错误:区分「用户取消」场景,并可携带需要完整展示的排查说明 */ export class VirtualPayError extends Error { errCode?: number /** 用户主动关闭收银台:调用方应静默处理,不要弹错误提示 */ cancelled = false /** * 排查说明(比 toast 能容纳的长得多)。 * 有值时调用方建议用 showModal 展示,而不是 showToast。 */ detail?: string constructor(message: string, errCode?: number) { super(message) this.name = 'VirtualPayError' this.errCode = errCode } } /** * 平台错误标识 → 展示文案。 * * wx.requestVirtualPayment 的 fail 回调形如 * { errCode: -15010, errMsg: 'requestVirtualPayment:fail PRODUCT_ID_NOT_PUBLISH' } * 其中 `:fail ` 之后的首个词是稳定可判定的标识,而 errCode 会随端和版本变化, * 因此这里按标识匹配;未收录的标识回落展示原始 errMsg,保证新错误不被吞掉。 * * 已核实:PRODUCT_ID_NOT_PUBLISH 表示「平台按该 productId 找不到已发布的道具」, * 常见于道具未发布,或道具 ID 与配置大小写/名称不一致(微信开放社区同报错案例)。 */ const PLATFORM_HINTS: Record = { PRODUCT_ID_NOT_PUBLISH: { message: '该套餐暂时无法购买', detail: '微信侧未找到已发布的对应道具。请确认【虚拟支付 → 道具管理】中的道具已「发布」(发布后约 10 分钟生效),且道具 ID 与套餐配置完全一致(区分大小写)。', }, PRODUCT_ID_NOT_EXIST: { message: '该套餐暂时无法购买', detail: '微信侧不存在该道具 ID。请核对【虚拟支付 → 道具管理】中的道具 ID 与套餐配置是否完全一致(区分大小写)。', }, } /** 取平台错误标识:`:fail ` 之后的首个词,统一大写 */ function platformToken(errMsg: string): string { const marker = ':fail' const at = errMsg.indexOf(marker) const tail = (at >= 0 ? errMsg.slice(at + marker.length) : errMsg).trim() return (tail.split(/[\s,;]/)[0] || '').toUpperCase() } /** 用户主动取消:各端文案不一(cancel / user cancel / USER_CANCEL),统一按关键字识别 */ function isCancelMessage(errMsg: string): boolean { const token = platformToken(errMsg) return token === 'CANCEL' || token.endsWith('_CANCEL') || /\bcancel\b/i.test(errMsg) } /** 把平台原始失败信息转成可直接展示的 VirtualPayError */ export function toVirtualPayError(err?: { errMsg?: string, errCode?: number }): VirtualPayError { const raw = err?.errMsg || '' const token = platformToken(raw) const hint = PLATFORM_HINTS[token] const result = new VirtualPayError(hint?.message || raw || '支付未完成', err?.errCode) result.cancelled = isCancelMessage(raw) result.detail = hint?.detail // 原始信息保留在控制台,便于联调定位(未收录的标识也靠它暴露) if (raw) console.warn(`[vpay] 平台支付失败: ${raw}`) return result } /** iOS 端最低微信客户端版本(官方要求 8.0.68) */ const IOS_MIN_VERSION = [8, 0, 68] as const function getWx(): WxLike | undefined { // typeof 守卫对未声明的全局变量也是安全的(不会抛 ReferenceError) if (typeof wx === 'undefined') return undefined return wx as unknown as WxLike } /** 当前环境是否为微信小程序 */ export function isWeixinMiniProgram(): boolean { return !!getWx()?.requestVirtualPayment } function compareVersion(cur: number[], base: readonly number[]): number { for (let i = 0; i < base.length; i++) { const c = cur[i] || 0 const b = base[i] || 0 if (c > b) return 1 if (c < b) return -1 } return 0 } /** 支付前校验:终端能力 + iOS 微信版本。不通过时 reason 可直接展示给用户。 */ export function checkPaySupport(): PaySupport { const api = getWx() if (!api?.requestVirtualPayment) { return { ok: false, reason: '请在微信小程序内购买(当前环境不支持微信虚拟支付)' } } try { const info = api.getSystemInfoSync?.() || {} if ((info.platform || '').toLowerCase() === 'ios') { const cur = String(info.version || '').split('.').map(n => Number.parseInt(n, 10) || 0) if (compareVersion(cur, IOS_MIN_VERSION) < 0) return { ok: false, reason: 'iOS 端需微信 8.0.68 及以上版本,请更新微信后再购买' } } } catch { // 取不到机型/版本信息时放行,交由平台侧最终校验 } return { ok: true } } /** 拉起虚拟支付。resolve 仅代表「平台侧支付流程走完」,不代表已发货。 */ export function requestVirtualPayment(params: VirtualPayParams): Promise { const api = getWx()?.requestVirtualPayment if (!api) { return Promise.reject(new VirtualPayError('请在微信小程序内购买(当前环境不支持微信虚拟支付)')) } return new Promise((resolve, reject) => { api({ signData: params.signData, paySig: params.paySig, signature: params.signature, mode: params.mode, success: () => resolve(), fail: (err: { errMsg?: string, errCode?: number }) => { reject(toVirtualPayError(err)) }, }) }) }