小程序 - 页面固定文案全部改读后台配置(购买、登录、资料、首页、分享、介绍页),代码里不再硬编码 - 已拥有课程时在当前页提示,不再跳购买页再提示 - 介绍页正文由代码常量改为读 settings.page.formula.content 服务端 - 课程相关报错文案(不存在 / 已下架 / 课时不存在 / 未购买 / 已过期 / 重复购买 / 未配置支付商品)改读配置,代码保留默认值兜底 - 新增支付总开关 pay.blocked:打开即拦截下单并下发配置里的提示;开关存在数据库里,开发库与生产库各一份(开发库已开,生产库未设) 后台 - 系统配置页按页面分组折叠,默认收起;认不出来的配置归入「其他」 配套数据(不在代码里):对应的 settings 行已写入生产库与开发库
253 lines
8.1 KiB
JavaScript
253 lines
8.1 KiB
JavaScript
// 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' };
|
|
},
|
|
});
|