fix(auth): 修复令牌过期处理和会话失效通知机制

- 移除 VITE_SERVICE_LOGOUT_CODES 中的 1002023000 状态码
- 将 VITE_SERVICE_EXPIRED_TOKEN_CODES 从 1002023001 改为 1002023000
- 修改 fetchRefreshToken 函数使用 params 传递 refreshToken 并设置 skipAuth
- 添加 skipAuth 配置选项避免给公开接口带上过期 access 头
- 实现 notifySessionExpired 函数确保并发请求只弹一次会话失效提示
- 在登录成功后复位会话失效标志以支持下次正常提示
- 更新 handleExpiredRequest 使用 refreshTokenPromise 替代 refreshTokenFn
This commit is contained in:
2026-05-18 08:29:51 +08:00
parent 543d1a59a9
commit 387eb41412
11 changed files with 187 additions and 179 deletions

View File

@@ -114,7 +114,9 @@ export async function fetchRefreshToken(refreshToken: string): Promise<ServiceRe
method: 'post',
params: { refreshToken },
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
skipAuth: true
skipAuth: true,
suppressErrorMessage: true,
skipTokenRefresh: true
});
if (result.error || !result.data) {

View File

@@ -10,6 +10,10 @@ declare module 'axios' {
* 避免给它们带上过期 access 头被网关拦截。
*/
skipAuth?: boolean;
/** 请求失败时不走通用错误 toast由调用方自行收敛提示。 */
suppressErrorMessage?: boolean;
/** 请求失败命中过期 access code 时,不再触发 refresh-token 流程。 */
skipTokenRefresh?: boolean;
}
}

View File

@@ -0,0 +1,32 @@
export const SESSION_EXPIRED_MESSAGE = '登录已失效,请重新登录';
export interface ErrorMessageSuppressOptions {
backendErrorCode: string;
suppressErrorMessage?: boolean;
logoutCodes: string[];
modalLogoutCodes: string[];
expiredTokenCodes: string[];
}
export interface BackendFailDeferOptions {
suppressErrorMessage?: boolean;
skipTokenRefresh?: boolean;
}
export function parseServiceCodes(codes?: string) {
return codes?.split(',').filter(Boolean) || [];
}
export function shouldDeferBackendFailToCaller(options: BackendFailDeferOptions) {
return Boolean(options.suppressErrorMessage && options.skipTokenRefresh);
}
export function shouldSuppressErrorMessage(options: ErrorMessageSuppressOptions) {
if (options.suppressErrorMessage) {
return true;
}
const handledCodes = [...options.logoutCodes, ...options.modalLogoutCodes, ...options.expiredTokenCodes];
return handledCodes.includes(options.backendErrorCode);
}

View File

@@ -5,6 +5,7 @@ import { localStg } from '@/utils/storage';
import { getServiceBaseURL } from '@/utils/service';
import { $t } from '@/locales';
import { applyApiEncrypt } from './api-encrypt';
import { parseServiceCodes, shouldDeferBackendFailToCaller, shouldSuppressErrorMessage } from './error-message';
import { getAuthorization, handleExpiredRequest, notifySessionExpired, showErrorMsg } from './shared';
import { withDedupe } from './dedupe';
import type { RequestInstanceState } from './type';
@@ -48,6 +49,15 @@ export const request = withDedupe(
const authStore = useAuthStore();
const responseCode = String(response.data.code);
if (
shouldDeferBackendFailToCaller({
suppressErrorMessage: response.config.suppressErrorMessage,
skipTokenRefresh: response.config.skipTokenRefresh
})
) {
return null;
}
function handleLogout() {
authStore.resetStore();
}
@@ -61,14 +71,14 @@ export const request = withDedupe(
// 当后端返回码命中 `logoutCodes` 时,表示登录态已失效,需要提示后退出登录
// 走 notifySessionExpired 而不是裸 resetStore保证并发请求只弹一次 toast、只清一次状态
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || [];
const logoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_LOGOUT_CODES);
if (logoutCodes.includes(responseCode)) {
notifySessionExpired();
return null;
}
// 当后端返回码命中 `modalLogoutCodes` 时,表示通过弹窗提示后再退出登录
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
const modalLogoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES);
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg];
@@ -92,8 +102,13 @@ export const request = withDedupe(
// 当后端返回码命中 `expiredTokenCodes` 时,表示 token 已过期,需要刷新 token
// `refreshToken` 接口不能再返回 `expiredTokenCodes` 中的错误码,否则会形成死循环,应返回 `logoutCodes` 或 `modalLogoutCodes`
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
const expiredTokenCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES);
if (expiredTokenCodes.includes(responseCode)) {
if (response.config.skipTokenRefresh) {
notifySessionExpired();
return null;
}
const success = await handleExpiredRequest(request.state);
if (success) {
const Authorization = getAuthorization();
@@ -117,15 +132,19 @@ export const request = withDedupe(
backendErrorCode = String(error.response?.data?.code || '');
}
// 这类错误信息已经通过弹窗展示,不再重复提示
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
if (modalLogoutCodes.includes(backendErrorCode)) {
return;
}
// token 过期时会自动刷新并重试请求,这里无需额外提示
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
if (expiredTokenCodes.includes(backendErrorCode)) {
const suppressErrorMessage = Boolean(error.config?.suppressErrorMessage);
const logoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_LOGOUT_CODES);
const modalLogoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES);
const expiredTokenCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES);
if (
shouldSuppressErrorMessage({
backendErrorCode,
suppressErrorMessage,
logoutCodes,
modalLogoutCodes,
expiredTokenCodes
})
) {
return;
}

View File

@@ -1,6 +1,7 @@
import { useAuthStore } from '@/store/modules/auth';
import { localStg } from '@/utils/storage';
import { fetchRefreshToken } from '../api';
import { SESSION_EXPIRED_MESSAGE } from './error-message';
import type { RequestInstanceState } from './type';
export function getAuthorization() {
@@ -51,7 +52,7 @@ export function notifySessionExpired() {
if (sessionExpiredNotified) return;
sessionExpiredNotified = true;
window.$message?.error('登录已过期,请重新登录');
window.$message?.error(SESSION_EXPIRED_MESSAGE);
const { resetStore } = useAuthStore();
resetStore();

View File

@@ -51,16 +51,27 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
clearAuthStorage();
authStore.$reset();
dictStore.resetDictCache();
objectContextStore.$reset();
// setup store 没有内置 $reset需要显式重置内部状态避免 token / userInfo 残留导致 isLogin 误判。
token.value = '';
Object.assign(userInfo, {
userId: '',
userName: '',
nickname: '',
roles: [],
buttons: []
});
if (!route.meta.constant) {
dictStore.resetDictCache();
objectContextStore.clearContext();
// 用路由名判断当前是否已在登录页,避免依赖 route.meta.constant ——
// workbench 等首页也是常量路由,原写法会让常量路由上的登出请求不跳转。
if (route.name !== 'login') {
await toLogin();
}
tabStore.cacheTabs();
routeStore.resetStore();
await routeStore.resetStore();
}
/** Record the user ID of the previous login session Used to compare with the current user ID on next login */

View File

@@ -149,9 +149,16 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
/** 重置 store */
async function resetStore() {
const routeStore = useRouteStore();
routeStore.$reset();
// setup store 没有内置 $reset需要显式重置内部状态。
// 否则 isInitConstantRoute / isInitAuthRoute 一直停在 true导致下面 initConstantRoute 早返,
// 路由被 resetVueRoutes 摘掉后无法重新注册,菜单和导航都会失效。
setIsInitConstantRoute(false);
setIsInitAuthRoute(false);
constantRoutes.value = [];
authRoutes.value = [];
menus.value = [];
cacheRoutes.value = [];
excludeCacheRoutes.value = [];
resetVueRoutes();

View File

@@ -3,7 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue';
import type { Component } from 'vue';
import { ElButton, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import { CircleCheckFilled, DeleteFilled, FolderOpened, VideoPause } from '@element-plus/icons-vue';
import { Box, DeleteFilled, VideoPause, VideoPlay } from '@element-plus/icons-vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
import { fetchGetProductOverviewSummary, fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api';
@@ -76,14 +76,14 @@ const statusNavMetas: StatusNavMeta[] = [
label: '启用产品',
description: '当前正常服务中的产品',
tone: 'teal',
icon: CircleCheckFilled
icon: VideoPlay
},
{
key: 'archived',
label: '归档产品',
description: '已完成阶段目标的产品',
tone: 'slate',
icon: FolderOpened
icon: Box
},
{
key: 'paused',
@@ -109,7 +109,7 @@ const operateVisible = ref(false);
const editingRow = ref<Api.Product.Product | null>(null);
const { routerPush } = useRouterPush();
const { dictData: directionOptions, getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const { getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const statusCounts = ref<Record<string, number>>({
active: 0,
@@ -129,29 +129,6 @@ const statusItems = computed(() =>
}))
);
const overviewMetrics = computed(() => [
{
label: '可见产品',
value: Object.values(statusCounts.value).reduce((sum, count) => sum + count, 0),
hint: '当前接口可查询到的产品总量'
},
{
label: '当前启用',
value: statusCounts.value.active ?? 0,
hint: '正在持续服务和维护的产品'
},
{
label: '产品方向',
value: directionOptions.value.length,
hint: '已加载的方向字典项数量'
},
{
label: '废弃产品',
value: statusCounts.value.abandoned ?? 0,
hint: '已明确停止建设的产品'
}
]);
function getDirectionLabel(directionCode?: string | null) {
return getDirectionDictLabel(directionCode, '--');
}
@@ -288,7 +265,7 @@ async function handleResetSearch() {
async function handleStatusChange(status: Api.Product.ProductStatusCode) {
selectedStatus.value = status;
await reloadProductTable(1);
await Promise.all([loadOverviewData(), reloadProductTable(1)]);
}
function openCreate() {
@@ -326,14 +303,6 @@ onMounted(async () => {
>
<div class="flex-col-stretch gap-16px xl:min-h-0">
<ElCard class="product-overview-card card-wrapper">
<div class="product-overview-card__stats">
<div v-for="item in overviewMetrics" :key="item.label" class="product-overview-card__stat">
<span class="product-overview-card__stat-label">{{ item.label }}</span>
<strong class="product-overview-card__stat-value">{{ item.value }}</strong>
<small class="product-overview-card__stat-hint">{{ item.hint }}</small>
</div>
</div>
<div class="product-status-panel__list">
<button
v-for="item in statusItems"
@@ -441,45 +410,10 @@ onMounted(async () => {
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.product-overview-card__stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.product-overview-card__stat {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 18px;
background-color: rgb(255 255 255 / 84%);
}
.product-overview-card__stat-label {
color: rgb(100 116 139 / 90%);
font-size: 13px;
}
.product-overview-card__stat-value {
color: rgb(15 23 42 / 94%);
font-size: 24px;
font-weight: 700;
line-height: 1.1;
}
.product-overview-card__stat-hint {
color: rgb(100 116 139 / 90%);
font-size: 12px;
line-height: 1.5;
}
.product-status-panel__list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.product-status-item {
@@ -594,10 +528,4 @@ onMounted(async () => {
flex-direction: column;
}
}
@media (width <= 640px) {
.product-overview-card__stats {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -71,7 +71,7 @@ const operateVisible = ref(false);
const editingRow = ref<Api.Project.Project | null>(null);
const { routerPush } = useRouterPush();
const { dictData: directionOptions, getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const { getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const { getLabel: getProjectTypeLabel } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
const statusCounts = ref<Record<string, number>>({
@@ -243,7 +243,7 @@ async function handleResetSearch() {
async function handleStatusChange(status: Api.Project.ProjectStatusCode) {
selectedStatus.value = status;
await reloadProjectTable(1);
await Promise.all([loadOverviewData(), reloadProjectTable(1)]);
}
function openCreate() {
@@ -282,7 +282,6 @@ onMounted(async () => {
<div class="flex-col-stretch gap-16px xl:min-h-0">
<ProjectOverviewCard
:status-counts="statusCounts"
:direction-count="directionOptions.length"
:selected-status="selectedStatus"
@status-change="handleStatusChange"
/>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Component } from 'vue';
import { CircleCheckFilled, DeleteFilled, DocumentAdd, FolderOpened, VideoPause } from '@element-plus/icons-vue';
import { Box, CircleCheckFilled, DeleteFilled, DocumentAdd, VideoPause, VideoPlay } from '@element-plus/icons-vue';
defineOptions({ name: 'ProjectOverviewCard' });
@@ -15,7 +15,6 @@ interface StatusNavMeta {
interface Props {
statusCounts: Record<string, number>;
directionCount: number;
selectedStatus: Api.Project.ProjectStatusCode;
}
@@ -40,7 +39,7 @@ const statusNavMetas: StatusNavMeta[] = [
label: '进行中',
description: '正在执行的项目',
tone: 'teal',
icon: CircleCheckFilled
icon: VideoPlay
},
{
key: 'paused',
@@ -54,7 +53,7 @@ const statusNavMetas: StatusNavMeta[] = [
label: '已完成',
description: '达成目标的项目',
tone: 'teal',
icon: FolderOpened
icon: CircleCheckFilled
},
{
key: 'cancelled',
@@ -68,7 +67,7 @@ const statusNavMetas: StatusNavMeta[] = [
label: '归档项目',
description: '已收口归档的历史项目',
tone: 'slate',
icon: FolderOpened
icon: Box
}
];
@@ -79,29 +78,6 @@ const statusItems = computed(() =>
}))
);
const overviewMetrics = computed(() => [
{
label: '总项目数',
value: Object.values(props.statusCounts).reduce((sum, count) => sum + count, 0),
hint: '当前接口可查询到的项目总量'
},
{
label: '进行中',
value: props.statusCounts.active ?? 0,
hint: '正在执行的项目'
},
{
label: '待开始',
value: props.statusCounts.pending ?? 0,
hint: '等待启动的项目'
},
{
label: '作废项目',
value: props.statusCounts.cancelled ?? 0,
hint: '已终止或取消推进的项目'
}
]);
function handleStatusClick(status: Api.Project.ProjectStatusCode) {
emit('status-change', status);
}
@@ -109,14 +85,6 @@ function handleStatusClick(status: Api.Project.ProjectStatusCode) {
<template>
<ElCard class="project-overview-card card-wrapper">
<div class="project-overview-card__stats">
<div v-for="item in overviewMetrics" :key="item.label" class="project-overview-card__stat">
<span class="project-overview-card__stat-label">{{ item.label }}</span>
<strong class="project-overview-card__stat-value">{{ item.value }}</strong>
<small class="project-overview-card__stat-hint">{{ item.hint }}</small>
</div>
</div>
<div class="project-status-panel__list">
<button
v-for="item in statusItems"
@@ -153,45 +121,10 @@ function handleStatusClick(status: Api.Project.ProjectStatusCode) {
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.project-overview-card__stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.project-overview-card__stat {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 18px;
background-color: rgb(255 255 255 / 84%);
}
.project-overview-card__stat-label {
color: rgb(100 116 139 / 90%);
font-size: 13px;
}
.project-overview-card__stat-value {
color: rgb(15 23 42 / 94%);
font-size: 24px;
font-weight: 700;
line-height: 1.1;
}
.project-overview-card__stat-hint {
color: rgb(100 116 139 / 90%);
font-size: 12px;
line-height: 1.5;
}
.project-status-panel__list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.project-status-item {
@@ -293,10 +226,4 @@ function handleStatusClick(status: Api.Project.ProjectStatusCode) {
flex-direction: column;
}
}
@media (width <= 640px) {
.project-overview-card__stats {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
SESSION_EXPIRED_MESSAGE,
shouldDeferBackendFailToCaller,
shouldSuppressErrorMessage
} from '../src/service/request/error-message';
describe('request session expired message handling', () => {
it('uses the unified session expired message', () => {
assert.equal(SESSION_EXPIRED_MESSAGE, '登录已失效,请重新登录');
});
it('suppresses raw backend errors when the request is marked silent', () => {
assert.equal(
shouldSuppressErrorMessage({
backendErrorCode: '500',
suppressErrorMessage: true,
logoutCodes: [],
modalLogoutCodes: [],
expiredTokenCodes: []
}),
true
);
});
it('lets refresh-token callers handle every backend failure silently', () => {
assert.equal(
shouldDeferBackendFailToCaller({
suppressErrorMessage: true,
skipTokenRefresh: true
}),
true
);
});
it('suppresses auth lifecycle error codes handled by request flow', () => {
assert.equal(
shouldSuppressErrorMessage({
backendErrorCode: '401',
logoutCodes: ['401'],
modalLogoutCodes: [],
expiredTokenCodes: []
}),
true
);
assert.equal(
shouldSuppressErrorMessage({
backendErrorCode: '7777',
logoutCodes: [],
modalLogoutCodes: ['7777'],
expiredTokenCodes: []
}),
true
);
assert.equal(
shouldSuppressErrorMessage({
backendErrorCode: '1002023000',
logoutCodes: [],
modalLogoutCodes: [],
expiredTokenCodes: ['1002023000']
}),
true
);
});
it('keeps normal backend errors visible', () => {
assert.equal(
shouldSuppressErrorMessage({
backendErrorCode: '500',
logoutCodes: ['401'],
modalLogoutCodes: ['7777'],
expiredTokenCodes: ['1002023000']
}),
false
);
});
});