Files
qitongxue-wx/src/utils/vpay.ts
2026-09-17 17:39:06 +08:00

169 lines
6.5 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.
/**
* 微信小程序虚拟支付(道具直购)客户端封装
*
* 官方 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<string, unknown>) => 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<string, { message: string, detail?: string }> = {
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<void> {
const api = getWx()?.requestVirtualPayment
if (!api) {
return Promise.reject(new VirtualPayError('请在微信小程序内购买(当前环境不支持微信虚拟支付)'))
}
return new Promise<void>((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))
},
})
})
}