request.ts 7.8 KB
import axios, { type AxiosInstance, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ADMIN_API_PREFIX, API_BASE_URL, LOGIN_PATH, REQUEST_TIMEOUT } from '@/config'
import { clearToken, getAccessToken, refreshToken as refreshTokenRef, setToken } from '@/utils/auth'
import type { CommonResult } from '@/types'

/**
 * axios 封装(对齐后端 parking-cloud 契约)
 *
 * 1. 自动附加 Authorization: Bearer <token>
 * 2. 自动解包 CommonResult:code === 0 时 resolve 出 data;否则 reject
 * 3. 401 → 先用刷新令牌续期并**重放原请求**;刷新也失败才清 token 跳登录
 * 4. 403 → 提示无权限
 *
 * 注意:后端对「未登录 / 无权限」返回的是 HTTP 200 + 响应体 code=401/403
 * (与参考工程 urbanops 一致,协议以**响应体 code** 为准),所以判定逻辑放在
 * 成功分支里,而不是 axios 的 error 分支。
 *
 * ⚠️ 为什么必须有第 3 条的续期:访问令牌只有 30 分钟(`system_oauth2_client`
 * 的 `access_token_validity_seconds`),而刷新令牌有 30 天。若 401 直接跳登录页,
 * 用户每半小时就被无理由踢出去一次,同时那个 30 天的刷新令牌永远是死数据。
 */

/** 401 只提示一次 */
let isReloginShowing = false

/** 带「是否已重试过」标记的请求配置,用于防止刷新后仍 401 时无限重放 */
type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean }

/** 刷新中的共享 Promise:401 往往并发出现(页面同时发多个请求),只允许发一次刷新 */
let refreshPromise: Promise<boolean> | null = null

/**
 * 用刷新令牌换新的访问令牌。
 *
 * 刻意用**裸 axios**而不是本文件的 service 实例,两个原因:
 *  1. service 的响应拦截器在 401 时又会走到本函数 → 无限递归;
 *  2. 避免 request.ts → api/login → request.ts 的循环 import。
 */
async function doRefreshToken(): Promise<boolean> {
  const rt = refreshTokenRef.value
  if (!rt) {
    return false
  }
  try {
    const { data } = await axios.post(
      `${API_BASE_URL}${ADMIN_API_PREFIX}/system/auth/refresh-token`,
      { refreshToken: rt },
      { timeout: REQUEST_TIMEOUT, headers: { 'Content-Type': 'application/json;charset=utf-8' } }
    )
    const token = data?.data
    if (data && Number(data.code) === 0 && token?.accessToken) {
      setToken(token.accessToken, token.refreshToken, token.expiresTime)
      return true
    }
    return false
  } catch (e) {
    // 刷新令牌本身过期/被撤销:交给上层跳登录页,这里不弹错(避免一次过期弹两条提示)
    return false
  }
}

function refreshOnce(): Promise<boolean> {
  if (!refreshPromise) {
    refreshPromise = doRefreshToken().finally(() => {
      refreshPromise = null
    })
  }
  return refreshPromise
}

const service: AxiosInstance = axios.create({
  baseURL: API_BASE_URL,
  timeout: REQUEST_TIMEOUT,
  headers: { 'Content-Type': 'application/json;charset=utf-8' },
  // 数组参数序列化成 `createTime=2026-01-01&createTime=2026-01-02`,
  // 而不是 axios 默认的 `createTime[]=...`。后端用
  // `@DateTimeFormat(pattern="yyyy-MM-dd") List<LocalDate> createTime` 接收,
  // 带方括号的 key 绑不上(列表页的日期范围查询就靠这一条)。
  paramsSerializer: { indexes: null }
})

service.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const token = getAccessToken()
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => Promise.reject(error)
)

service.interceptors.response.use(
  (response) => {
    // 文件下载等非 JSON 响应原样返回
    if (response.request?.responseType === 'blob' || response.config?.responseType === 'blob') {
      return response
    }
    const res = response.data as CommonResult
    // 兼容非 CommonResult 结构(如第三方回调),原样返回
    if (res == null || typeof res.code === 'undefined') {
      return response.data
    }

    const code = Number(res.code)
    if (code === 0) {
      return res.data
    }

    if (code === 401) {
      return handleUnauthorized(response.config, res.msg)
    }
    if (code === 403) {
      ElMessage.error(res.msg || '没有访问权限')
      return Promise.reject(new Error(res.msg || '没有访问权限'))
    }

    ElMessage.error(res.msg || '请求失败')
    return Promise.reject(new Error(res.msg || '请求失败'))
  },
  (error) => {
    // HTTP 层错误(网络异常 / 超时 / 5xx)
    const status = error?.response?.status
    if (status === 401) {
      // 正常契约下 401 是 HTTP 200 + 响应体 code,走不到这里;
      // 但 Spring Security 过滤器层直接拒绝时会是真的 401,同样尝试续期
      return handleUnauthorized(error.config, error?.message)
    }
    let msg = error?.message || '网络异常'
    if (msg.includes('timeout')) msg = '请求超时,请稍后重试'
    else if (status === 404) msg = '请求地址不存在'
    else if (status === 500) msg = '服务器异常,请联系管理员'
    else if (status === 502 || status === 503) msg = '服务暂时不可用'
    ElMessage.error(msg)
    return Promise.reject(error)
  }
)

/**
 * 401 处理:先尝试用刷新令牌续期并重放原请求;续期不成功才提示重新登录。
 *
 * @param config 原请求配置(重放用)
 * @param msg    后端返回的提示语
 * @returns 重放请求的结果(续期成功时);否则 reject
 */
async function handleUnauthorized(config?: InternalAxiosRequestConfig, msg?: string): Promise<any> {
  const original = config as RetryConfig | undefined
  // 已经重试过的请求不再续期:否则「续期成功但业务接口仍返回 401」会无限循环
  if (original && !original._retry) {
    original._retry = true
    if (await refreshOnce()) {
      return service.request(original)
    }
  }
  showRelogin()
  throw new Error(msg || '账号未登录')
}

function showRelogin() {
  if (isReloginShowing) return
  isReloginShowing = true
  ElMessageBox.confirm('登录状态已过期,请重新登录', '提示', {
    confirmButtonText: '重新登录',
    cancelButtonText: '取消',
    type: 'warning'
  })
    .then(() => {
      clearToken()
      window.location.href = `${LOGIN_PATH}?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`
    })
    .catch(() => {})
    .finally(() => {
      isReloginShowing = false
    })
}

/**
 * 对外暴露的方法包装:返回值类型直接是解包后的 data
 *
 * ⚠️ 这里必须是**独立的对象**,不能写成 `const request = service` 之后再挂方法——
 *    那样 request 与 service 是同一个实例,`request.post = (...) => service.post(...)`
 *    会把 service.post 覆盖成自己,调用时无限递归(RangeError: Maximum call stack size exceeded)。
 */
const request = {
  get: <T = any>(url: string, config?: AxiosRequestConfig) => service.get(url, config) as Promise<T>,
  post: <T = any>(url: string, data?: any, config?: AxiosRequestConfig) =>
    service.post(url, data, config) as Promise<T>,
  put: <T = any>(url: string, data?: any, config?: AxiosRequestConfig) =>
    service.put(url, data, config) as Promise<T>,
  delete: <T = any>(url: string, config?: AxiosRequestConfig) => service.delete(url, config) as Promise<T>,

  /** 文件下载(Excel 导出等) */
  download: async (url: string, params?: any, filename?: string) => {
    const response = await service.get(url, { params, responseType: 'blob' })
    const blob = new Blob([response.data])
    const link = document.createElement('a')
    link.href = URL.createObjectURL(blob)
    link.download = filename || 'download.xlsx'
    link.click()
    URL.revokeObjectURL(link.href)
  }
}

export { service }
export default request