Merge remote-tracking branch 'origin/main'
This commit is contained in:
4
.env
4
.env
@@ -33,7 +33,7 @@ VITE_SERVICE_SUCCESS_CODE=0
|
||||
|
||||
# 后端登出状态码;当返回这些 code 时,前端会登出并跳回登录页
|
||||
# 典型场景:token 无效、登录状态失效、账号被踢下线、后端要求强制重新登录
|
||||
VITE_SERVICE_LOGOUT_CODES=401,1002023000
|
||||
VITE_SERVICE_LOGOUT_CODES=401
|
||||
|
||||
# 后端弹窗登出状态码;当返回这些 code 时,前端会先弹窗再登出
|
||||
# 典型场景:账号被禁用、密码已重置、登录安全策略触发、需要用户先确认后再重新登录
|
||||
@@ -41,7 +41,7 @@ VITE_SERVICE_MODAL_LOGOUT_CODES=7777,7778
|
||||
|
||||
# token 过期状态码;当返回这些 code 时,前端会尝试刷新 token 并重发请求
|
||||
# 典型场景:accessToken 过期但 refreshToken 仍有效、短期登录凭证失效但允许无感续期
|
||||
VITE_SERVICE_EXPIRED_TOKEN_CODES=1002023001
|
||||
VITE_SERVICE_EXPIRED_TOKEN_CODES=1002023000
|
||||
|
||||
# 静态路由模式下定义的超级管理员角色
|
||||
VITE_STATIC_SUPER_ROLE=R_SUPER
|
||||
|
||||
@@ -106,14 +106,25 @@ export async function fetchGetUserInfo(force = false): Promise<ServiceRequestRes
|
||||
*
|
||||
* @param refreshToken 刷新 token
|
||||
*/
|
||||
export function fetchRefreshToken(refreshToken: string) {
|
||||
return request<Api.Auth.LoginToken>({
|
||||
export async function fetchRefreshToken(refreshToken: string): Promise<ServiceRequestResult<Api.Auth.LoginToken>> {
|
||||
// 后端要求 refreshToken 通过 query 参数传递,且 Content-Type 为 form-urlencoded
|
||||
// skipAuth: 不注入过期 access 头,否则会被网关拦下死循环(网关一律校验 Authorization,不看 PermitAll)
|
||||
const result = await request<BackendLoginToken>({
|
||||
url: `${SYSTEM_SERVICE_PREFIX}/auth/refresh-token`,
|
||||
method: 'post',
|
||||
data: {
|
||||
refreshToken
|
||||
}
|
||||
params: { refreshToken },
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
skipAuth: true
|
||||
});
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return result as ServiceRequestResult<Api.Auth.LoginToken>;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: mapLoginToken(result.data)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { InternalAxiosRequestConfig } from 'axios';
|
||||
declare module 'axios' {
|
||||
interface AxiosRequestConfig {
|
||||
dedupe?: boolean;
|
||||
/**
|
||||
* 跳过 Authorization 注入。
|
||||
*
|
||||
* 用于公开接口(refresh-token / login / register 等 PermitAll 路径),
|
||||
* 避免给它们带上过期 access 头被网关拦截。
|
||||
*/
|
||||
skipAuth?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { localStg } from '@/utils/storage';
|
||||
import { getServiceBaseURL } from '@/utils/service';
|
||||
import { $t } from '@/locales';
|
||||
import { applyApiEncrypt } from './api-encrypt';
|
||||
import { getAuthorization, handleExpiredRequest, showErrorMsg } from './shared';
|
||||
import { getAuthorization, handleExpiredRequest, notifySessionExpired, showErrorMsg } from './shared';
|
||||
import { withDedupe } from './dedupe';
|
||||
import type { RequestInstanceState } from './type';
|
||||
|
||||
@@ -29,8 +29,12 @@ export const request = withDedupe(
|
||||
return response.data.data;
|
||||
},
|
||||
async onRequest(config) {
|
||||
const Authorization = getAuthorization();
|
||||
Object.assign(config.headers, { Authorization });
|
||||
// skipAuth 为 true 的请求不注入 Authorization——避免给公开接口(如 refresh-token)
|
||||
// 带上过期 access 头被网关拦截(网关只看 Authorization,不区分路由是否 PermitAll)
|
||||
if (!config.skipAuth) {
|
||||
const Authorization = getAuthorization();
|
||||
Object.assign(config.headers, { Authorization });
|
||||
}
|
||||
applyApiEncrypt(config);
|
||||
|
||||
return config;
|
||||
@@ -55,10 +59,11 @@ export const request = withDedupe(
|
||||
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg);
|
||||
}
|
||||
|
||||
// 当后端返回码命中 `logoutCodes` 时,表示用户需要退出登录并跳转到登录页
|
||||
// 当后端返回码命中 `logoutCodes` 时,表示登录态已失效,需要提示后退出登录
|
||||
// 走 notifySessionExpired 而不是裸 resetStore:保证并发请求只弹一次 toast、只清一次状态
|
||||
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || [];
|
||||
if (logoutCodes.includes(responseCode)) {
|
||||
handleLogout();
|
||||
notifySessionExpired();
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ export function getAuthorization() {
|
||||
|
||||
/** 刷新 token */
|
||||
async function handleRefreshToken() {
|
||||
const { resetStore } = useAuthStore();
|
||||
|
||||
const rToken = localStg.get('refreshToken') || '';
|
||||
const { error, data } = await fetchRefreshToken(rToken);
|
||||
if (!error) {
|
||||
@@ -22,25 +20,48 @@ async function handleRefreshToken() {
|
||||
return true;
|
||||
}
|
||||
|
||||
resetStore();
|
||||
notifySessionExpired();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function handleExpiredRequest(state: RequestInstanceState) {
|
||||
if (!state.refreshTokenFn) {
|
||||
state.refreshTokenFn = handleRefreshToken();
|
||||
if (!state.refreshTokenPromise) {
|
||||
state.refreshTokenPromise = handleRefreshToken();
|
||||
}
|
||||
|
||||
const success = await state.refreshTokenFn;
|
||||
const success = await state.refreshTokenPromise;
|
||||
|
||||
setTimeout(() => {
|
||||
state.refreshTokenFn = null;
|
||||
state.refreshTokenPromise = null;
|
||||
}, 1000);
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
// 会话失效一次性锁:保证 N 个并发请求只弹一次 toast、只 resetStore 一次
|
||||
let sessionExpiredNotified = false;
|
||||
|
||||
/**
|
||||
* 通知用户会话已失效,弹一次 toast 后清状态、跳登录。
|
||||
*
|
||||
* 多个并发请求触发时只会真正执行一次;登录成功后由 resetSessionExpiredFlag() 复位。
|
||||
*/
|
||||
export function notifySessionExpired() {
|
||||
if (sessionExpiredNotified) return;
|
||||
sessionExpiredNotified = true;
|
||||
|
||||
window.$message?.error('登录已过期,请重新登录');
|
||||
|
||||
const { resetStore } = useAuthStore();
|
||||
resetStore();
|
||||
}
|
||||
|
||||
/** 登录成功后复位一次性锁,让下一次会话失效仍能正常提示 */
|
||||
export function resetSessionExpiredFlag() {
|
||||
sessionExpiredNotified = false;
|
||||
}
|
||||
|
||||
export function showErrorMsg(state: RequestInstanceState, message: string) {
|
||||
if (!state.errMsgStack?.length) {
|
||||
state.errMsgStack = [];
|
||||
|
||||
@@ -3,5 +3,7 @@ export interface RequestInstanceState {
|
||||
refreshTokenPromise: Promise<boolean> | null;
|
||||
/** 请求错误信息栈 */
|
||||
errMsgStack: string[];
|
||||
// 索引签名是 @sa/axios 的 defaultState 类型约束(要求 Record<string, unknown>)的硬要求,不能删
|
||||
// 字段名对齐已通过把 shared.ts 里的 refreshTokenFn 全部改成 refreshTokenPromise 来消除隐患
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRoute } from 'vue-router';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useLoading } from '@sa/hooks';
|
||||
import { clearAuthApiCache, fetchGetUserInfo, fetchLogin } from '@/service/api';
|
||||
import { resetSessionExpiredFlag } from '@/service/request/shared';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { localStg } from '@/utils/storage';
|
||||
import { SetupStoreId } from '@/enum';
|
||||
@@ -149,6 +150,9 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
|
||||
token.value = loginToken.token;
|
||||
|
||||
// 复位会话失效一次性锁,让下一次会话失效仍能正常提示
|
||||
resetSessionExpiredFlag();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user