request.ts
7.8 KB
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
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