// pages/buy/buy.js // // 商业化页面:所有购买入口都落到这里。 // 职责是「确认订单 → 拉起微信支付 → 确认发货」。 // // 两种进入方式: // - 带 ?code=xxx(从详情页进来):只显示这一条 // - 不带参数:列出全部条目,一条一个购买按钮 const api = require('../../utils/api'); const format = require('../../utils/format'); const store = require('../../utils/store'); const app = getApp(); const POLL_INTERVAL = 1500; // 微信的发货推送实测要 13~17 秒才到,原来只等 12 秒,用户多半先拿到兜底提示而不是成功提示 const POLL_MAX = 20; /** iPhone 上跑 Apple 支付要求微信客户端 ≥ 这个版本 */ const IOS_MIN_WECHAT = '8.0.68'; /** 当前微信客户端版本号,取不到就返回空串。 */ function clientVersion() { try { const info = typeof wx.getAppBaseInfo === 'function' ? wx.getAppBaseInfo() : wx.getSystemInfoSync(); return (info && info.version) || ''; } catch (err) { return ''; } } /** 版本号比大小:'8.0.68' vs '8.0.5' → 正数表示前者新。 */ function compareVersion(a, b) { const left = String(a).split('.').map(Number); const right = String(b).split('.').map(Number); const len = Math.max(left.length, right.length); for (let i = 0; i < len; i += 1) { const l = left[i] || 0; const r = right[i] || 0; if (l !== r) return l - r; } return 0; } Page({ data: { code: '', loading: true, buying: false, owned: false, title: '', coverUrl: '', priceYuan: '', list: [], // 页面上的固定文案全部来自后台配置,这里只放容器 textBuy: '', textBuyList: '', textPaying: '', textOwned: '', textEmpty: '', }, onLoad(options) { // 页面可能被直接打开(分享、扫码),先等初始化把站点配置读回来 app.ready().then(() => this.applyTexts()); const code = options && options.code; if (code) { this.setData({ code: code }); this.load(code); return; } this.loadList(); }, applyTexts() { this.setData({ textBuy: store.cfg('course.buy.button'), textBuyList: store.cfg('course.buy.buttonList'), textPaying: store.cfg('course.buy.paying'), textOwned: store.cfg('course.buy.owned'), textEmpty: store.cfg('course.buy.empty'), }); }, load(code) { api .getCourse(code) .then((course) => { this.setData({ loading: false, owned: course.owned, title: course.title || '', coverUrl: course.coverUrl || '', priceYuan: format.fenToYuan(course.priceFen), }); // 已经拥有就不用再买了,避免重复下单 if (course.owned) this.rejectOut(store.cfg('course.tip.owned')); }) .catch((err) => { this.setData({ loading: false }); // 条目被下架或编号拼错,后端会返回错误,这里按同样的方式退出 this.rejectOut((err && err.message) || store.cfg('course.tip.notFound')); }); }, /** 不带编号进来:列出全部条目,让用户自己挑。 */ loadList() { api .getCourses() .then((courses) => { this.setData({ loading: false, list: (courses || []).map((c) => ({ code: c.code, title: c.title, priceYuan: format.fenToYuan(c.priceFen), owned: c.owned, })), }); }) .catch((err) => { this.setData({ loading: false }); wx.showToast({ title: (err && err.message) || store.cfg('course.tip.loadFailed'), icon: 'none' }); }); }, /** 进错页面(没带编号、条目不存在、已经买过):提示一句然后退出去。 */ rejectOut(message) { wx.showToast({ title: message, icon: 'none' }); setTimeout(() => this.back(), 1500); }, /** 退回上一页;没有上一页(直接打开)就回知识页,避免停在死页面。 */ back() { if (getCurrentPages().length > 1) { wx.navigateBack(); return; } wx.switchTab({ url: '/pages/kecheng/kecheng' }); }, /** * 购买:下单 → 拉起支付 → 轮询订单状态确认发货。 * 编号优先取按钮上的 data-code(列表模式),没有就用页面参数(单条模式)。 */ buyNow(e) { const code = (e && e.currentTarget && e.currentTarget.dataset && e.currentTarget.dataset.code) || this.data.code; if (this.data.buying || !code) return; // 购买的前置条件:用户主动使用过登录功能。静默登录不算。 if (!store.isLoggedIn()) { wx.showModal({ title: store.cfg('course.login.title'), content: store.cfg('course.login.desc'), confirmText: store.cfg('course.login.confirm'), success: (res) => { if (res.confirm) { wx.navigateTo({ url: '/pages/LoginOrOutDetails/LoginOrOutDetails' }); } }, }); return; } if (typeof wx.requestVirtualPayment !== 'function') { wx.showToast({ title: '当前微信版本不支持支付,请升级微信', icon: 'none' }); return; } // iOS 版本预检放在下单之前:版本不够时不该先建出一笔待支付订单 if (!this.checkIosVersion()) return; this.setData({ buying: true }); wx.showLoading({ title: store.cfg('course.loading.paying'), mask: true }); api .createOrder(code) .then((order) => { wx.hideLoading(); return api.requestVirtualPayment(order.payData).then(() => order.outTradeNo); }) .then((outTradeNo) => this.waitForDelivery(outTradeNo)) .catch((err) => { wx.hideLoading(); this.setData({ buying: false }); // 用户主动取消不算错误 if (err && err.errMsg && err.errMsg.indexOf('cancel') >= 0) return; // 拉起支付失败时微信给的是 errMsg/errno,服务端报错给的是 message。 // 原来只弹「支付失败」,把排查线索全丢了,所以原样带出来。 const detail = (err && (err.errMsg || err.message)) || '未知错误'; const errno = err && (err.errno || err.errCode); wx.showModal({ title: '支付失败', content: errno ? detail + '\n错误码:' + errno : detail, showCancel: false, }); }); }, /** * iPhone 上 Apple 支付要求微信 ≥ 8.0.68,低了拉不起来。 * 只在 iOS 上校验;安卓等没有这个限制,取不到版本号也不拦(交给微信自己报错)。 */ checkIosVersion() { if (api.devicePlatform() !== 'ios') return true; const version = clientVersion(); if (!version || compareVersion(version, IOS_MIN_WECHAT) >= 0) return true; wx.showModal({ content: '请先升级微信', showCancel: false, confirmText: '知道了', }); return false; }, /** * 支付成功后权益由服务端发货推送发放,存在秒级延迟,这里轮询确认。 * 即便轮询全部超时也不会丢单:服务端有定时查单兜底。 */ waitForDelivery(outTradeNo) { let attempts = 0; const poll = () => { attempts += 1; return api.getOrder(outTradeNo).then((order) => { if (order.status === 2) { this.setData({ buying: false, owned: true }); wx.showToast({ title: store.cfg('course.tip.buySuccess'), icon: 'success', mask: true }); // 单条模式从详情页进来,买完退回去;列表模式留在原地刷新 if (this.data.code) { setTimeout(() => this.back(), 1500); } else { this.loadList(); } return; } if (attempts >= POLL_MAX || order.status === 3 || order.status === 4) { this.setData({ buying: false }); wx.showToast({ title: store.cfg('course.tip.payPending'), icon: 'none', duration: 3000 }); return; } return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)).then(poll); }); }; return poll(); }, onShareAppMessage() { return { title: store.cfg('share.title'), path: '/pages/index/index' }; }, });