request.js 10.4 KB
/*
 * @Date: 2022-09-19 14:11:06
 * @LastEditors: hookehuyr hookehuyr@gmail.com
 * @LastEditTime: 2026-02-02 18:32:59
 * @FilePath: /manulife-weapp/src/utils/request.js
 * @Description: HTTP 请求封装(简化版)
 */
import axios from 'axios-miniprogram'
import Taro from '@tarojs/taro'
import { parseQueryString } from './tools'
import BASE_URL, { REQUEST_DEFAULT_PARAMS } from './config'
import { routerStore } from '@/stores/router'

/**
 * @description 获取 sessionid 的工具函数
 * - sessionid 由 authRedirect.refreshSession 写入
 * - 每次请求前动态读取,避免旧会话导致的 401
 * @returns {string|null} sessionid或null
 */
export const getSessionId = () => {
  try {
    return Taro.getStorageSync('sessionid') || null
  } catch (error) {
    console.error('获取sessionid失败:', error)
    return null
  }
}

/**
 * @description 设置 sessionid(一般不需要手动调用)
 * - 正常情况下由 authRedirect.refreshSession 写入
 * - 保留该方法用于极端场景的手动修复/兼容旧逻辑
 * - 自动清理重复的 cookie(防止后端返回重复的 set-cookie)
 * @param {string} sessionid cookie 字符串
 * @returns {void} 无返回值
 */
export const setSessionId = sessionid => {
  try {
    if (!sessionid) {
      return
    }

    // 自动清理重复的 cookie
    // 例如:"PHPSESSID=xxx; ...,PHPSESSID=xxx; ..." -> "PHPSESSID=xxx; ..."
    const cleaned = cleanupDuplicateCookies(sessionid)

    Taro.setStorageSync('sessionid', cleaned)
  } catch (error) {
    console.error('设置sessionid失败:', error)
  }
}

/**
 * @description 清理重复的 cookie
 * @description 如果 cookie 字符串中有重复项,只保留第一个
 * @param {string} cookies cookie 字符串
 * @returns {string} 清理后的 cookie 字符串
 *
 * @example
 * cleanupDuplicateCookies('PHPSESSID=xxx; ...,PHPSESSID=xxx; ...')
 * // 返回: 'PHPSESSID=xxx; ...'
 */
function cleanupDuplicateCookies(cookies) {
  if (!cookies || typeof cookies !== 'string') {
    return cookies
  }

  // 按逗号分割(重复的 cookie 用逗号连接)
  const parts = cookies.split(',')

  // 如果只有一个部分,直接返回
  if (parts.length === 1) {
    return cookies
  }

  // 提取 cookie 名称(用于去重)
  const extractCookieName = (cookieStr) => {
    const match = cookieStr.match(/^([^=]+)=/)
    return match ? match[1].trim() : null
  }

  // 去重:只保留第一次出现的 cookie
  const seen = new Set()
  const uniqueParts = []

  for (const part of parts) {
    const name = extractCookieName(part)
    if (name && !seen.has(name)) {
      seen.add(name)
      uniqueParts.push(part)
    }
  }

  // 如果去重后只剩一个,直接返回
  if (uniqueParts.length === 1) {
    return uniqueParts[0]
  }

  // 否则用逗号连接
  return uniqueParts.join(',')
}

/**
 * @description 清空 sessionid(一般不需要手动调用)
 * @returns {void} 无返回值
 */
export const clearSessionId = () => {
  try {
    Taro.removeStorageSync('sessionid')
  } catch (error) {
    console.error('清空sessionid失败:', error)
  }
}

/**
 * @description 检测是否为 AI 测试环境
 * - Vitest 测试环境自动启用 Mock
 * - @returns {boolean} true=测试环境,false=非测试环境
 */
const isAITestEnvironment = () => {
  // 检测 Vitest 环境变量(小程序环境没有 process,需要先检查)
  if (typeof process !== 'undefined' && process.env && process.env.VITEST === 'true') {
    return true
  }
  // 检测全局 __vitest__ 标记
  if (typeof window !== 'undefined' && window.__vitest__) {
    return true
  }
  return false
}

/**
 * @description POST Mock 路由器
 * - 根据请求 URL 路由到对应的 Mock 函数
 * @param {string} url - 请求 URL
 * @param {any} data - 请求体
 * @returns {Promise<{code:number, msg:string, data:any}>} Mock 响应
 */
const postMockRouter = async (url, data) => {
  // 动态导入 mockData(避免循环依赖)
  const { mockPostAPI } = await import('./mockData.js')
  return mockPostAPI(url, data)
}

/**
 * @description axios 实例
 * - 统一 baseURL / timeout
 * - 通过拦截器处理:默认参数、401 跳转登录页、弱网降级
 * - AI 测试环境:POST 请求自动路由到 Mock
 */
const service = axios.create({
  baseURL: BASE_URL,
  timeout: 5000,
})

let has_shown_timeout_modal = false

/**
 * @description 判断是否为超时错误
 * @param {Error} error 请求错误对象
 * @returns {boolean} true=超时,false=非超时
 */
const is_timeout_error = (error) => {
  const msg = String(error?.message || error?.errMsg || '')
  if (error?.code === 'ECONNABORTED') return true
  return msg.toLowerCase().includes('timeout')
}

/**
 * @description 判断是否为网络错误(断网/弱网/请求失败等)
 * @param {Error} error 请求错误对象
 * @returns {boolean} true=网络错误,false=非网络错误
 */
const is_network_error = (error) => {
  const msg = String(error?.message || error?.errMsg || '')
  const raw = (() => {
    try {
      return JSON.stringify(error) || ''
    } catch (e) {
      return ''
    }
  })()
  const lower = (msg + ' ' + raw).toLowerCase()
  if (lower.includes('request:fail')) return true
  if (lower.includes('request fail')) return true
  if (lower.includes('network error')) return true
  if (lower.includes('failed to fetch')) return true
  if (lower.includes('the internet connection appears to be offline')) return true
  if (lower.includes('err_blocked_by_client')) return true
  if (lower.includes('blocked_by_client')) return true
  return false
}

/**
 * @description 是否需要触发弱网/断网降级逻辑
 * - 超时:直接触发
 * - 网络错误:直接触发(避免 wifi 但无网场景漏判)
 * @param {Error} error 请求错误对象
 * @returns {Promise<boolean>} true=需要降级,false=不需要
 */
const should_handle_bad_network = async (error) => {
  if (is_timeout_error(error)) return true
  return is_network_error(error)
}

/**
 * @description 处理请求超时/弱网错误
 * - 弹出弱网提示
 * @returns {Promise<void>} 无返回值
 */
const handle_request_timeout = async () => {
  if (has_shown_timeout_modal) return
  has_shown_timeout_modal = true

  // 提示用户检查网络连接
  try {
    await Taro.showToast({
      title: '网络连接异常,请检查网络设置',
      icon: 'none',
      duration: 2000
    })
  } catch (e) {
    console.error('show weak network toast failed:', e)
  }
}

// 请求拦截器:合并默认参数
service.interceptors.request.use(
  config => {
    // 解析 URL 参数并合并
    const url = config.url || ''
    let url_params = {}
    if (url.includes('?')) {
      url_params = parseQueryString(url)
      config.url = url.split('?')[0]
    }

    // 优先级:调用传参 > URL参数 > 默认参数
    config.params = {
      ...REQUEST_DEFAULT_PARAMS,
      ...url_params,
      ...(config.params || {})
    }

    /**
     * 动态获取 sessionid 并设置到请求头
     * - 确保每个请求都带上最新的 sessionid
     * - 注意:axios-miniprogram 的 headers 可能不存在,需要先兜底
     */
    const sessionid = getSessionId()
    if (sessionid) {
      config.headers = config.headers || {}
      config.headers.cookie = sessionid
    }

    // 增加时间戳
    if (config.method === 'get') {
      config.params = { ...config.params, timestamp: (new Date()).valueOf() }
    }

    // 【AI 测试】POST 请求标记为 Mock
    if (isAITestEnvironment() && config.method === 'post') {
      // 保存原始 URL 和数据,供响应拦截器使用
      config.__aiTestMock = true
      config.__originalUrl = url
      config.__mockData = config.data
      console.log(`[AI Test Mock] 拦截 POST 请求: ${url}`)
    }

    return config
  },
  error => {
    console.error('请求拦截器异常:', error)
    return Promise.reject(error)
  }
)

// 响应拦截器:401 跳转登录页 / 弱网降级
service.interceptors.response.use(
  /**
   * @description 响应成功拦截器
   * - AI 测试环境:POST 请求返回 Mock 数据
   * - 处理 401 未授权,跳转到登录页
   * - 处理其他自定义错误消息
   */
  async response => {
    // 【AI 测试】处理 Mock 请求
    const config = response.config
    if (config && config.__aiTestMock) {
      const mockResult = await postMockRouter(config.__originalUrl, config.__mockData)
      // 构造伪造的响应对象,与真实响应格式一致
      return { data: mockResult }
    }

    const res = response.data

    // 401 未授权处理
    if (res.code === 401) {
      // 获取当前页面路径
      const pages = Taro.getCurrentPages()
      const currentPage = pages[pages.length - 1]
      const currentPath = currentPage?.route || ''

      // 保存当前路径到 router store(用于登录后跳转回原页面)
      if (currentPath && currentPath !== 'pages/login/index') {
        const store = routerStore()  // 调用函数获取 store 实例
        store.add('/' + currentPath)
        console.log('401: 保存当前路径到 router store:', '/' + currentPath)
      } else {
        console.log('401: 当前路径为空或是登录页,不保存路径:', currentPath)
      }

      // 根据导航栈长度选择跳转方式
      if (pages.length <= 1) {
        // 如果导航栈只有1个页面,使用 reLaunch 清空栈并跳转到登录页
        // 这样可以避免"cannot navigate back"错误
        console.log('401: 导航栈只有1页,使用 reLaunch')
        Taro.reLaunch({
          url: '/pages/login/index'
        }).catch(() => {
          console.warn('reLaunch 到登录页失败')
        })
      } else {
        // 如果导航栈有多个页面,使用 redirectTo 替换当前页面
        console.log('401: 导航栈有多个页面,使用 redirectTo')
        Taro.redirectTo({
          url: '/pages/login/index'
        }).catch(() => {
          // 如果跳转失败(如已经在登录页),则忽略
          console.warn('跳转登录页失败,可能已在登录页')
        })
      }
    }

    // 处理特殊消息(不需要显示的错误)
    if (['预约ID不存在'].includes(res.msg)) {
      res.show = false
    }

    return response
  },
  /**
   * @description 响应失败拦截器
   * - 处理网络错误、超时等
   */
  async error => {
    // 处理弱网/断网
    if (await should_handle_bad_network(error)) {
      handle_request_timeout()
    }

    return Promise.reject(error)
  }
)

export default service