Files
YelayMaths/yelaymaths/utils/api.js
T
2026-09-30 15:08:16 +08:00

240 lines
8.3 KiB
JavaScript
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.
/**
* 统一请求层与接口封装。
*
* 替代旧版本「每个接口手写一遍 wx.request + Promise」的写法。
* 职责:
* - 自动附带 Authorization
* - 统一解包 { code, message, data, requestId }
* - token 失效自动刷新,刷新失败自动重新登录
*/
const config = require('../config/index');
const store = require('./store');
/** 进行中的会话恢复,避免并发 401 触发多次恢复流程 */
let recovering = null;
function staticUrl(path) {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
return config.STATIC_BASE_URL.replace(/\/+$/, '') + '/' + String(path).replace(/^\/+/, '');
}
/**
* 下单设备。平台按设备路由支付渠道(iOS 走 Apple 支付,只能用户自己找 App Store 退),
* 服务端靠这个字段判断一笔单能不能主动退款,所以下单时要一起报上去。
*/
function platform() {
try {
if (typeof wx.getDeviceInfo === 'function') return wx.getDeviceInfo().platform || 'unknown';
return wx.getSystemInfoSync().platform || 'unknown';
} catch (err) {
return 'unknown';
}
}
function normalizeError(body, statusCode) {
if (body && typeof body.code === 'number') {
return { code: body.code, message: body.message || '请求失败', requestId: body.requestId };
}
return { code: -1, message: '服务异常(' + statusCode + ')' };
}
/** 底层请求。不做鉴权重试,返回完整响应体。 */
function rawRequest(options) {
const header = { 'content-type': 'application/json' };
if (options.auth !== false) {
const token = store.getToken();
if (token) header.Authorization = 'Bearer ' + token;
}
return new Promise((resolve, reject) => {
wx.request({
url: config.API_BASE_URL.replace(/\/+$/, '') + options.url,
method: options.method || 'GET',
data: options.data,
header,
timeout: config.TIMEOUT,
success(res) {
resolve({ statusCode: res.statusCode, body: res.data || {} });
},
fail() {
reject({ code: -1, message: '网络异常,请检查网络后重试' });
},
});
});
}
/** 带鉴权的请求。token 失效时自动恢复会话并重试一次。 */
function request(options) {
return rawRequest(options).then(({ statusCode, body }) => {
if (body.code === 0) return body.data;
const needLogin = statusCode === 401 || body.code === 2000 || body.code === 2001;
if (needLogin && options.auth !== false && options.retried !== true) {
return recoverSession()
.then(() => request(Object.assign({}, options, { retried: true })))
.catch(() => {
store.reset();
throw { code: 2000, message: '登录已过期,请重新进入小程序' };
});
}
throw normalizeError(body, statusCode);
});
}
/**
* 恢复会话。两级兜底:
* 1. 用 refreshToken 换新 accessToken(快,不打扰用户)
* 2. refreshToken 也没有或已失效时,重新走一次微信静默登录
*
* 第 2 级是必需的:「退出登录」会清空全部 token,
* 此后任何需要鉴权的操作(例如再次点「登录注册」改昵称)都必须先重新登录,
* 否则必然 401。
*/
function recoverSession() {
if (recovering) return recovering;
recovering = refreshAccessToken()
.catch(() => login())
.finally(() => {
recovering = null;
});
return recovering;
}
function refreshAccessToken() {
const refreshToken = store.getRefreshToken();
if (!refreshToken) return Promise.reject({ code: 2000, message: '未登录' });
return rawRequest({
url: '/v1/auth/refresh',
method: 'POST',
data: { refreshToken },
auth: false,
}).then(({ body }) => {
if (body.code !== 0) throw { code: 2000, message: '登录已过期' };
store.setTokens(body.data.accessToken);
return body.data.accessToken;
});
}
// ── 登录 ──────────────────────────────────────────────────────────────────
/** wx.login 换 code,再用 code 换服务端 token。 */
function login() {
return new Promise((resolve, reject) => {
wx.login({
success(res) {
if (!res.code) {
reject({ code: 3002, message: '微信登录失败,请重试' });
return;
}
request({
url: '/v1/auth/wx-login',
method: 'POST',
data: { code: res.code },
auth: false,
})
.then((data) => {
store.setTokens(data.accessToken, data.refreshToken);
store.setUser(data.user);
resolve(data);
})
.catch(reject);
},
fail() {
reject({ code: 3002, message: '微信登录失败,请重试' });
},
});
});
}
function logout() {
return request({ url: '/v1/auth/logout', method: 'POST' }).catch(() => null).then(() => {
store.reset();
});
}
/**
* 用户主动登录:把数据库里的「已登录」标记置 1。
*
* 静默登录(login)不会碰这个标记——它只负责建立身份。只有用户亲自点了登录,
* 才算「主动使用过登录功能」,购买资格也以这个标记为准,而不是有没有 token。
*/
function activeLogin() {
return request({ url: '/v1/auth/login', method: 'POST' });
}
// ── 首页内容 ──────────────────────────────────────────────────────────────
const api = {
staticUrl,
login,
logout,
activeLogin,
getConfig: () => request({ url: '/v1/config', auth: false }),
getBanners: () => request({ url: '/v1/banners', auth: false }),
getHomeCourses: () => request({ url: '/v1/home/courses', auth: false }),
getHomeFreeCourses: () => request({ url: '/v1/home/free-courses', auth: false }),
getArticles: (page, pageSize) =>
request({ url: '/v1/articles?page=' + (page || 1) + '&pageSize=' + (pageSize || 10), auth: false }),
getArticle: (id) => request({ url: '/v1/articles/' + id, auth: false }),
getTips: () => request({ url: '/v1/tips', auth: false }),
// ── 课程 ────────────────────────────────────────────────────────────
getCourses: () => request({ url: '/v1/courses' }),
getCourse: (code) => request({ url: '/v1/courses/' + code }),
getLessons: (code) => request({ url: '/v1/courses/' + code + '/lessons' }),
getPlayUrl: (code, chapterId, playId) =>
request({
url:
'/v1/courses/' +
code +
'/play?chapterId=' +
encodeURIComponent(chapterId) +
'&playId=' +
encodeURIComponent(playId),
}),
getFreeLessons: (seriesNo) =>
request({ url: '/v1/free-lessons' + (seriesNo === undefined ? '' : '?seriesNo=' + seriesNo), auth: false }),
// ── 用户 ────────────────────────────────────────────────────────────
getMe: () => request({ url: '/v1/me' }),
updateMe: (payload) => request({ url: '/v1/me', method: 'PATCH', data: payload }),
getEntitlements: () => request({ url: '/v1/me/entitlements' }),
// ── 订单与支付 ──────────────────────────────────────────────────────
createOrder: (courseCode) =>
request({
url: '/v1/orders',
method: 'POST',
data: { courseCode: courseCode, terminal: platform() },
}),
getOrder: (outTradeNo) => request({ url: '/v1/orders/' + outTradeNo }),
};
/**
* 拉起虚拟支付。
*
* 支付成功只代表「钱付了」,权益由服务端收到发货推送后发放,
* 因此这里不直接开通课程,而是让调用方轮询订单状态。
*/
function requestVirtualPayment(payData) {
return new Promise((resolve, reject) => {
wx.requestVirtualPayment({
signData: payData.signData,
mode: payData.mode,
paySig: payData.paySig,
signature: payData.signature,
success: resolve,
fail: reject,
});
});
}
module.exports = Object.assign(api, { requestVirtualPayment, devicePlatform: platform });