/** * 统一请求层与接口封装。 * * 替代旧版本「每个接口手写一遍 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 });