import Taro from '@tarojs/taro';
|
import { PUBLIC_HEADER } from '../constants/app';
|
import packageJson from '../../package.json';
|
import {
|
getOrCreateLocalId,
|
getXpClientString,
|
getStamp,
|
makeSign,
|
toBase64,
|
getStorage,
|
objectSort,
|
} from '../utils/tools';
|
import { _XP_SECRET, _UID } from '../constants/api';
|
import { jumpTo } from './jump';
|
|
const CODE_SUCCESS = 200
|
const CODE_AUTH_EXPIRED_ARRAY = [12010301, 12010302, 12010303, 12010304, 12010305, 12010306, 12010320, 12010002];
|
// 12010301, "登录状态已过期,请重新登录", "otp缓存已过期"),
|
// 12010302, "登录状态已过期,请重新登录", "otp已过期"),
|
// 12010303, "登录状态已过期,请重新登录", "otp不能为空"),
|
// 12010304, "登录状态已过期,请重新登录", "otp校验失败"),
|
// 12010305, "登录状态已过期,请重新登录", "otp签名校验失败"
|
// 12010306, "登录状态已过期,请重新登录", "otp校验参数失败"),
|
// 12010320, "登录状态已过期,请重新登录", "otp校验失败"),
|
// 12010002, "用户不存在,请确认后重试","可能正式跟预发的otp串了,一般用户不会接触到这问题,但还是要做处理"),
|
|
|
/**
|
* 封装网络请求
|
* // NOTE 需要注意 RN 不支持 *StorageSync,此处用 async/await 解决
|
* @param {*} options
|
* @needLogin 是否需要校验登录态(所有SSO的接口都需要设为true),因算签名相对比较耗性能,不需要就不计算
|
*/
|
export default async function fetch(options: any, needLogin?: boolean) {
|
// 自动将 GET 请求 URL 中的 query params 合并到 payload 参与签名,后端的验签是这么实现的,可以保障 query 参数也不会被篡改
|
// 保证无论参数写在 URL query string 还是 payload,签名结果均一致
|
if (needLogin && (options.method || 'POST').toUpperCase() === 'GET') {
|
try {
|
const qIdx = (options.url as string || '').indexOf('?');
|
if (qIdx !== -1) {
|
const urlQueryParams: Record<string, string> = {};
|
(options.url as string).slice(qIdx + 1).split('&').forEach((part: string) => {
|
const eq = part.indexOf('=');
|
if (eq !== -1) {
|
urlQueryParams[decodeURIComponent(part.slice(0, eq))] = decodeURIComponent(part.slice(eq + 1));
|
}
|
});
|
// payload 中同名 key 优先级高于 URL query params
|
options.payload = { ...urlQueryParams, ...(options.payload || {}) };
|
}
|
} catch (err) {
|
console.warn(`⚠️ [request] 解析 URL query 参数失败, url: ${options.url}`, err);
|
}
|
}
|
|
const { url, method = 'POST', showToast = true, autoLogin = true, skipException = false, originResponse = false } = options;
|
|
// 对payload进行排序,保证 payload 里面的顺序和生成签名时 payload 的顺序一致,因为后端要拿 payload 计算签名,所以顺序是必须一致的,同时,后端也需要对 payload 进行排序,后端通过 XpClientCheck 注解
|
if (needLogin) {
|
options.payload = objectSort(options.payload, { keyUpperCase: false })
|
}
|
|
const payload = options.payload;
|
|
const header = await buildHeader(options, needLogin)
|
|
if (!header) return Promise.reject({ code: -1, msg: '网络不可用' })
|
|
return Taro.request({
|
url,
|
method,
|
data: payload,
|
header
|
})
|
.then(async (res) => {
|
// res为包含cookies、header的经过小程序封装的数据
|
// 第一层data为接口返回数据,包含接口状态码
|
// 第二层data一般为业务数据
|
const { data } = res.data;
|
let { code } = res.data;
|
code = code ? code : res.data.status;
|
|
// DEBUG: 当 data 为空时打印原始响应,排查接口返回格式问题
|
if (data === undefined || data === null) {
|
console.warn(`⚠️ [request] API 返回 data 为空, url: ${url}`);
|
console.warn('⚠️ [request] 原始响应 res.data:', res.data);
|
}
|
|
if (skipException) { // 跳过异常处理
|
const authExpired = CODE_AUTH_EXPIRED_ARRAY.indexOf(code) !== -1;
|
if (authExpired && autoLogin && needLogin) { // 登录相关的异常除外
|
return Promise.reject(res.data)
|
}
|
return res.data;
|
}
|
|
// 需要获取初始数据
|
if (originResponse) {
|
return res;
|
}
|
|
if (code && code !== CODE_SUCCESS) {
|
return Promise.reject(res.data);
|
}
|
|
// 此为接口返回业务数据
|
return data
|
})
|
.catch((err) => {
|
console.log('request err:', url);
|
console.log(err);
|
|
let errData = err;
|
if (process.env.TARO_ENV === 'alipay' && errData.status && errData.status !== 200 && errData.data) {
|
errData = errData.data;
|
if (skipException) { // 跳过异常处理
|
const code = errData.code ? errData.code : errData.status;
|
const authExpired = CODE_AUTH_EXPIRED_ARRAY.indexOf(code) !== -1;
|
if (!(authExpired && autoLogin && needLogin)) { // 登录相关的异常除外
|
return errData;
|
}
|
}
|
}
|
|
const authExpired = CODE_AUTH_EXPIRED_ARRAY.indexOf(errData.code) !== -1;
|
const defaultMsg = authExpired ? '登录过期' : errData.msg || '网络异常';
|
const search = (payload && payload.targetAppId) ? `?appId=${payload.targetAppId}` : '';
|
|
if (showToast) {
|
Taro.showToast({ title: defaultMsg, icon: 'none' });
|
}
|
if (authExpired && autoLogin && needLogin) {
|
jumpTo({ url: `/pages/user/login${search}` });
|
}
|
|
return Promise.reject(errData)
|
});
|
}
|
|
async function buildHeader(options, needLogin) {
|
const { method = 'POST', payload, extraHeader = {} } = options;
|
const header = Object.assign({}, PUBLIC_HEADER);
|
|
// 处理header
|
// 获取网络状态
|
const networkTypeRes = await Taro.getNetworkType()
|
const network = networkTypeRes.errMsg === 'getNetworkType:ok' ? networkTypeRes.networkType : 'unknow';
|
|
if (networkTypeRes.networkType === 'none') {
|
Taro.showToast({
|
title: '网络不可用,请检查',
|
icon: 'none',
|
})
|
return false;
|
}
|
|
// 获取本地的设备码,无则随机生成一个
|
const di = await getOrCreateLocalId()
|
|
// 组装计算XP-Client
|
const si = Taro.getSystemInfoSync() || {}; // 此处如果断网情况下会返回 undifined
|
si.model = si.model.replace('<', '').replace('>', '').replace(',', '');
|
const xpClient = {
|
di,
|
ve: packageJson.version,
|
pn: 'mini_program_xiaopeng',
|
br: si.model,
|
mo: si.model,
|
sv: si.system,
|
nt: network,
|
t: new Date().getTime(),
|
st: si.platform.includes('ios') ? '2' : '1',
|
re: `${si.screenWidth}*${si.screenHeight}`,
|
}
|
|
header['XP-Client'] = getXpClientString(xpClient);
|
header['XP-Nonce'] = getStamp();
|
|
// 获取uid
|
const uid = await getStorage(_UID) || '-1';
|
header['XP-Uid'] = uid.toString();
|
|
// 获取设备id
|
header['XP-Deviceid'] = Taro.getStorageSync('wxOpenId') || 'default_device_id';
|
|
let extraData = {};
|
|
if (extraHeader && typeof extraHeader === 'object' && Object.keys(extraHeader).length > 0) {
|
Object.keys(extraHeader).forEach(key => {
|
header[key] = extraHeader[key];
|
});
|
extraData = extraHeader;
|
}
|
|
if (needLogin) {
|
// 获取otp
|
const otp = await getStorage('otp') || '';
|
header['XP-Sort-Payload'] = '1' // 告诉后端payload已经排序,后端也需要排序,解决小红书 iOS 对 payload 顺序进行了调整的问题
|
|
// 第一次算签名
|
const midData = {
|
'XP-Uid': header['XP-Uid'],
|
// 'Content-Type': PUBLIC_HEADER['Content-Type'],
|
// 'XP-Authorization': PUBLIC_HEADER['XP-Authorization'],
|
'XP-Encryption-Type': PUBLIC_HEADER['XP-Encryption-Type'],
|
'XP-Client-Type': PUBLIC_HEADER['XP-Client-Type'],
|
'XP-Appid': PUBLIC_HEADER['XP-Appid'],
|
'XP-Client': header['XP-Client'],
|
'XP-Client-Encoding': PUBLIC_HEADER['XP-Client-Encoding'],
|
'XP-Nonce': header['XP-Nonce'],
|
// 'XP-Signature': PUBLIC_HEADER['XP-Signature'],
|
'XP-Deviceid': header['XP-Deviceid'],
|
'XP-Sort-Payload': header['XP-Sort-Payload'],
|
...extraData,
|
}
|
const sign_1st = makeSign(method, midData, payload, _XP_SECRET);
|
const author = toBase64({ xpSign: sign_1st, otp });
|
header['XP-Authorization'] = author;
|
|
|
|
// 第二次算签名
|
const lastData = {
|
'XP-Uid': header['XP-Uid'],
|
// 'Content-Type': PUBLIC_HEADER['Content-Type'],
|
'XP-Authorization': header['XP-Authorization'],
|
'XP-Encryption-Type': PUBLIC_HEADER['XP-Encryption-Type'],
|
'XP-Client-Type': PUBLIC_HEADER['XP-Client-Type'],
|
'XP-Appid': PUBLIC_HEADER['XP-Appid'],
|
'XP-Client': header['XP-Client'],
|
'XP-Client-Encoding': PUBLIC_HEADER['XP-Client-Encoding'],
|
'XP-Nonce': header['XP-Nonce'],
|
// 'XP-Signature': PUBLIC_HEADER['XP-Signature'],
|
'XP-Deviceid': header['XP-Deviceid'],
|
'XP-Sort-Payload': header['XP-Sort-Payload'],
|
...extraData,
|
}
|
const sign_2nd = makeSign(method, lastData, payload, _XP_SECRET);
|
header['XP-Signature'] = sign_2nd;
|
}
|
|
return header;
|
}
|