卢春龙
2026-08-17 f525b6c3cc105bd1a997d893937689bebcc9570d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
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;
}