Skip to content

Commit 05b5e5f

Browse files
committed
feat: 增强认证流程,处理令牌失效和过期情况,优化用户体验
1 parent 587fd62 commit 05b5e5f

File tree

7 files changed

+178
-133
lines changed

7 files changed

+178
-133
lines changed

src/axios/axios.js

Lines changed: 69 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -7,57 +7,72 @@ const BASE_URL = import.meta.env.VITE_APP_BASE_API;
77
const axiosInstance = axios.create({
88
baseURL: BASE_URL,
99
withCredentials: true, // 携带 HttpOnly refresh cookie
10-
// timeout: 8000,
1110
});
1211

1312
const TOKEN_KEY = "token";
1413
const TOKEN_EXPIRES_AT_KEY = "tokenExpiresAt";
1514
const REFRESH_TOKEN_EXPIRES_AT_KEY = "refreshTokenExpiresAt";
15+
const USER_INFO_KEY = "userInfo";
1616

17-
const FORCE_LOGOUT_CODES = new Set([
18-
"ZC_ERROR_INVALID_REFRESH_TOKEN",
19-
"ZC_ERROR_REFRESH_TOKEN_EXPIRED",
20-
]);
17+
// 后端统一错误码
18+
const ZC_ERROR = {
19+
NEED_LOGIN: "ZC_ERROR_NEED_LOGIN", // 未携带令牌
20+
NEED_LOGOUT: "ZC_ERROR_NEED_LOGOUT", // 令牌无效/过期/已吊销/刷新失败
21+
FORBIDDEN: "ZC_ERROR_FORBIDDEN", // 已登录但无权限
22+
};
2123

2224
export const TOKEN_REFRESHED_EVENT_NAME = "auth:token-refreshed";
2325

2426
// 用独立 refresh client,避免走 axiosInstance 的响应拦截器(防递归)
2527
const refreshClient = axios.create({
2628
baseURL: BASE_URL,
2729
withCredentials: true,
28-
// timeout: 8000,
2930
});
3031

3132
let refreshPromise = null;
3233

33-
const normalizeUrlPath = (url) => {
34-
if (!url) return "";
35-
try {
36-
// 处理绝对 URL
37-
if (url.startsWith("http://") || url.startsWith("https://")) {
38-
return new URL(url).pathname;
39-
}
40-
// 处理相对 URL:确保能被 URL 解析
41-
const base = BASE_URL?.startsWith("http") ? BASE_URL : "http://localhost";
42-
return new URL(url, base).pathname;
43-
} catch {
44-
return String(url);
45-
}
46-
};
34+
// 防止并发请求触发重复跳转
35+
let isRedirecting = false;
4736

48-
const isRefreshEndpoint = (url) => {
49-
const path = normalizeUrlPath(url);
50-
return path === "/account/refresh-token";
51-
};
37+
const getErrorCode = (error) => error?.response?.data?.code || error?.code;
5238

53-
const shouldForceLogoutByCode = (code) => FORCE_LOGOUT_CODES.has(code);
39+
/**
40+
* 清除本地所有认证相关状态
41+
*/
42+
const clearLocalAuth = () => {
43+
localStorage.removeItem(TOKEN_KEY);
44+
localStorage.removeItem(TOKEN_EXPIRES_AT_KEY);
45+
localStorage.removeItem(REFRESH_TOKEN_EXPIRES_AT_KEY);
46+
localStorage.removeItem(USER_INFO_KEY);
47+
localStorage.removeItem("sudo_token");
48+
localStorage.removeItem("sudo_token_expires_at");
49+
localStorage.removeItem("sudo_token_duration");
50+
};
5451

55-
const getErrorCode = (error) => error?.response?.data?.code || error?.code;
52+
/**
53+
* 处理 ZC_ERROR_NEED_LOGOUT:令牌已失效(过期/吊销/刷新失败等)
54+
* 清除本地令牌 → 通知 store → 跳转登录页
55+
*/
56+
export const handleNeedLogout = () => {
57+
if (isRedirecting) return;
58+
isRedirecting = true;
59+
60+
clearLocalAuth();
61+
window.dispatchEvent(new CustomEvent("forceLogout"));
62+
window.location.href = "/?reason=session_expired";
63+
};
5664

57-
const triggerForceLogout = () => {
58-
if (typeof window !== "undefined") {
59-
window.dispatchEvent(new CustomEvent("forceLogout"));
60-
}
65+
/**
66+
* 处理 ZC_ERROR_NEED_LOGIN:刷新令牌也失败后
67+
* 清除本地登录态 → 通知 store → 跳转登录页
68+
*/
69+
export const handleNeedLogin = () => {
70+
if (isRedirecting) return;
71+
isRedirecting = true;
72+
73+
clearLocalAuth();
74+
window.dispatchEvent(new CustomEvent("forceLogout"));
75+
window.location.href = "/";
6176
};
6277

6378
const emitTokenRefreshed = (refreshData) => {
@@ -77,12 +92,8 @@ const emitTokenRefreshed = (refreshData) => {
7792
const saveRefreshedToken = (refreshData) => {
7893
localStorage.setItem(TOKEN_KEY, refreshData.token);
7994

80-
// 这里保留后端给的 expires_at(若你 store 用 JWT exp 为准,也没问题)
8195
if (refreshData.expires_at) {
8296
localStorage.setItem(TOKEN_EXPIRES_AT_KEY, refreshData.expires_at);
83-
} else {
84-
// 如果后端不返回 expires_at,你也可以选择清掉,让 store 走 JWT exp
85-
// localStorage.removeItem(TOKEN_EXPIRES_AT_KEY);
8697
}
8798

8899
if (refreshData.refresh_expires_at) {
@@ -99,7 +110,7 @@ const performRefreshRequest = async () => {
99110
if (data.status !== "success" || !data.token) {
100111
const err = new Error(data.message || "Refresh token failed");
101112
err.code = data.code || "REFRESH_FAILED";
102-
err.response = resp; // 保留一些上下文(可选)
113+
err.response = resp;
103114
throw err;
104115
}
105116

@@ -123,13 +134,11 @@ axiosInstance.interceptors.request.use(
123134
(config) => {
124135
const t = localStorage.getItem(TOKEN_KEY);
125136

126-
// AxiosHeaders / plain object 都兼容
127137
config.headers = config.headers || {};
128138

129139
if (t) {
130140
config.headers.Authorization = `Bearer ${t}`;
131141
} else {
132-
// 避免发送 Bearer null
133142
try {
134143
delete config.headers.Authorization;
135144
} catch {}
@@ -140,56 +149,51 @@ axiosInstance.interceptors.request.use(
140149
(error) => Promise.reject(error)
141150
);
142151

143-
// 响应拦截器:401 → 刷新 → 重放
152+
// 响应拦截器:根据后端统一错误码处理认证问题
144153
axiosInstance.interceptors.response.use(
145154
(response) => response,
146155
async (error) => {
147156
const originalRequest = error?.config;
148157
const response = error?.response;
149158

150159
// 没有响应(网络断开/超时)直接抛出
151-
if (!originalRequest || !response) {
160+
if (!response || !originalRequest) {
152161
return Promise.reject(error);
153162
}
154163

155-
// 刷新接口本身不参与重试
156-
if (isRefreshEndpoint(originalRequest.url)) {
164+
const errorCode = getErrorCode(error);
165+
166+
// ZC_ERROR_NEED_LOGOUT: 令牌已失效(过期/吊销/刷新失败等)
167+
// 必须清除本地令牌,跳转登录页
168+
if (errorCode === ZC_ERROR.NEED_LOGOUT) {
169+
handleNeedLogout();
157170
return Promise.reject(error);
158171
}
159172

160-
// 只处理 401,且同一个请求只重试一次
161-
if (response.status === 401 && !originalRequest._retry) {
162-
originalRequest._retry = true;
173+
// ZC_ERROR_NEED_LOGIN: 未携带令牌
174+
// 先尝试刷新令牌,刷新成功则重放请求;失败则清除登录态并跳转
175+
if (errorCode === ZC_ERROR.NEED_LOGIN && !originalRequest._retryAfterRefresh) {
176+
originalRequest._retryAfterRefresh = true;
163177

164178
try {
165179
const newToken = await requestTokenRefresh();
166-
167-
// 兼容 headers 类型:合并而不是替换
168180
originalRequest.headers = originalRequest.headers || {};
169181
originalRequest.headers.Authorization = `Bearer ${newToken}`;
170-
171182
return axiosInstance(originalRequest);
172-
} catch (refreshError) {
173-
const code = getErrorCode(refreshError);
174-
175-
// 1) 明确的 refresh token 失效 → 强制登出
176-
if (shouldForceLogoutByCode(code)) {
177-
triggerForceLogout();
178-
return Promise.reject(refreshError);
179-
}
180-
181-
// 2) refresh 接口返回 401/403(有些后端不带业务 code)→ 也登出更合理
182-
const refreshStatus = refreshError?.response?.status;
183-
if (refreshStatus === 401 || refreshStatus === 403) {
184-
triggerForceLogout();
185-
return Promise.reject(refreshError);
186-
}
187-
188-
// 3) 网络错误/500:不强制登出,让上层决定(可弹提示)
189-
return Promise.reject(refreshError);
183+
} catch {
184+
// 刷新失败,清除登录态并跳转
185+
handleNeedLogin();
186+
return Promise.reject(error);
190187
}
191188
}
192189

190+
// 重试过仍然 NEED_LOGIN,直接跳转
191+
if (errorCode === ZC_ERROR.NEED_LOGIN) {
192+
handleNeedLogin();
193+
return Promise.reject(error);
194+
}
195+
196+
// 其他错误(包括 ZC_ERROR_FORBIDDEN)由调用方自行处理
193197
return Promise.reject(error);
194198
}
195199
);

src/components/account/LoginDialog.vue

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
<template>
22
<v-dialog v-model="dialogVisible" width="400">
3-
<template v-slot:activator="{ props }">
4-
<slot name="activator" :props="props">
5-
<v-btn rounded="xl" v-bind="props">登录</v-btn>
6-
</slot>
7-
</template>
3+
84

95
<v-card rounded="xl">
106
<v-card-title></v-card-title>

src/pages/app/account/login.vue

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
<template>
22
<div>
3-
<AuthCard subtitle="登录你的账户">
3+
<AuthCard :subtitle="subtitle">
4+
<v-alert
5+
v-if="reason === 'session_expired'"
6+
type="warning"
7+
variant="tonal"
8+
class="mb-4"
9+
text="您的登录状态已失效,请重新登录"
10+
/>
411
<LoginForm @login-success="handleLoginSuccess" @login-error="handleLoginError"/>
512
</AuthCard>
613
</div>
@@ -22,6 +29,12 @@ export default {
2229
const router = useRouter();
2330
const authStore = useAuthStore();
2431
32+
const reason = route.query.reason || null;
33+
34+
const subtitle = reason === "session_expired"
35+
? "会话已过期,请重新登录"
36+
: "登录你的账户";
37+
2538
// Capture redirect from query or sessionStorage
2639
const redirectFromQuery = route.query.redirect
2740
? decodeURIComponent(route.query.redirect)
@@ -30,8 +43,8 @@ export default {
3043
authStore.setAuthRedirectUrl(redirectFromQuery);
3144
}
3245
33-
// Check if user is already logged in
34-
if (localuser.isLogin.value === true) {
46+
// Check if user is already logged in (skip if session expired)
47+
if (!reason && localuser.isLogin.value === true) {
3548
router.push(authStore.consumeAuthRedirectUrl());
3649
}
3750
@@ -50,6 +63,8 @@ export default {
5063
};
5164
5265
return {
66+
reason,
67+
subtitle,
5368
handleLoginSuccess,
5469
handleLoginError,
5570
};

src/pages/app/account/logout.vue

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,39 +12,40 @@
1212
</v-container>
1313
</template>
1414

15-
<script>
16-
import {localuser} from "@/services/localAccount";
17-
import {useHead} from "@unhead/vue";
18-
19-
export default {
20-
data() {
21-
return {
22-
titlemessage: "正在退出账户",
23-
log: "",
24-
};
25-
},
26-
setup() {
27-
useHead({
28-
title: "退出",
29-
});
30-
},
31-
async created() {
32-
this.log = "正在退出账户...";
33-
34-
try {
35-
// 使用新的异步注销方法
36-
await localuser.logout(true);
37-
this.titlemessage = "已成功退出";
38-
this.log = "您已安全退出账户。请关闭此标签页并刷新其他标签页。";
39-
40-
// 3秒后跳转到登录页面
41-
setTimeout(() => {
42-
this.$router.push('/app/account/login');
43-
}, 3000);
44-
} catch (error) {
45-
this.titlemessage = "退出时发生错误";
46-
this.log = error.message || "未知错误,请稍后重试";
47-
}
48-
},
49-
};
15+
<script setup>
16+
import { ref, onMounted } from "vue";
17+
import { useRouter } from "vue-router";
18+
import { useAuthStore } from "@/stores/auth";
19+
import { useNotificationStore } from "@/stores/notification";
20+
import { useHead } from "@unhead/vue";
21+
22+
useHead({
23+
title: "退出",
24+
});
25+
26+
const router = useRouter();
27+
const authStore = useAuthStore();
28+
const notificationStore = useNotificationStore();
29+
const titlemessage = ref("正在退出账户");
30+
const log = ref("");
31+
32+
onMounted(async () => {
33+
log.value = "正在退出账户...";
34+
35+
try {
36+
await authStore.logout(true);
37+
} catch {
38+
// 即使出错也继续清理
39+
}
40+
41+
// 清除其他 store 中的用户数据
42+
notificationStore.resetUnreadCount();
43+
44+
titlemessage.value = "已成功退出";
45+
log.value = "您已安全退出账户,正在返回首页...";
46+
47+
setTimeout(() => {
48+
router.push("/");
49+
}, 1000);
50+
});
5051
</script>

src/pages/index.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ const loadFeed = async (isInitial = false) => {
152152
const res = await PostsService.getFeed({
153153
cursor: isInitial ? undefined : cursor.value,
154154
limit: 20,
155-
includeReplies: false
155+
includeReplies: false,
156+
followingOnly: feedType.value === 'following'
156157
});
157158
158159
if (isInitial) {

src/services/postsService.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,11 +323,13 @@ export const PostsService = {
323323
* @param {string} options.cursor - 分页游标
324324
* @param {number} options.limit - 每页数量
325325
* @param {boolean} options.includeReplies - 是否包含回复
326+
* @param {boolean} options.followingOnly - 是否仅显示已关注用户的帖子
326327
*/
327-
async getFeed({ cursor, limit = 20, includeReplies = false } = {}) {
328+
async getFeed({ cursor, limit = 20, includeReplies = false, followingOnly = false } = {}) {
328329
try {
329330
const params = { limit, include_replies: String(includeReplies) };
330331
if (cursor) params.cursor = cursor;
332+
if (followingOnly) params.following_only = 'true';
331333
const response = await axios.get('/posts/feed', { params });
332334
return normalizeListResponse(response.data);
333335
} catch (error) {

0 commit comments

Comments
 (0)