169 lines
6.5 KiB
TypeScript
169 lines
6.5 KiB
TypeScript
/**
|
||
* 微信小程序虚拟支付(道具直购)客户端封装
|
||
*
|
||
* 官方 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))
|
||
},
|
||
})
|
||
})
|
||
}
|