Merge pull request 'feat: 完善鉴权模块' (#142) from fix/auth into develop
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/142
This commit was merged in pull request #142.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
// ============================================================
|
||||
// HTTP API 客户端
|
||||
// 职责:封装 REST API 请求(auth、conversations 等)
|
||||
// 内置 401 拦截 + 自动刷新 + 重试机制
|
||||
// ============================================================
|
||||
|
||||
const API_BASE = "/api";
|
||||
@@ -11,9 +12,69 @@ interface ApiResponse<T> {
|
||||
status: number;
|
||||
}
|
||||
|
||||
// ---- 认证回调(由 AuthProvider 注入,避免循环依赖) ----
|
||||
|
||||
interface AuthCallbacks {
|
||||
getAccessToken: () => string | null;
|
||||
getRefreshToken: () => string | null;
|
||||
onRefreshSuccess: (user: AuthUser, accessToken: string, refreshToken: string) => void;
|
||||
onRefreshFailed: () => void;
|
||||
}
|
||||
|
||||
let authCallbacks: AuthCallbacks | null = null;
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
/** 由 AuthProvider 在初始化时调用,注入认证回调。 */
|
||||
export function setAuthCallbacks(callbacks: AuthCallbacks): void {
|
||||
authCallbacks = callbacks;
|
||||
}
|
||||
|
||||
/** 不需要认证的公开路径。 */
|
||||
const PUBLIC_PATHS = new Set([
|
||||
"/auth/register",
|
||||
"/auth/login",
|
||||
"/auth/refresh",
|
||||
]);
|
||||
|
||||
function isPublicPath(path: string): boolean {
|
||||
return PUBLIC_PATHS.has(path);
|
||||
}
|
||||
|
||||
/** 尝试用 refresh token 换取新的 access token。 */
|
||||
async function doRefresh(): Promise<boolean> {
|
||||
const rt = authCallbacks?.getRefreshToken();
|
||||
if (!rt) return false;
|
||||
|
||||
const res = await refreshTokenDirect(rt);
|
||||
if (res.data) {
|
||||
authCallbacks?.onRefreshSuccess(
|
||||
res.data.user,
|
||||
res.data.access_token,
|
||||
res.data.refresh_token
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
authCallbacks?.onRefreshFailed();
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 带并发保护的刷新:多个 401 只触发一次 refresh。 */
|
||||
async function refreshWithLock(): Promise<boolean> {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = doRefresh().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
// ---- 核心请求函数 ----
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
options: RequestInit = {},
|
||||
_retry = false
|
||||
): Promise<ApiResponse<T>> {
|
||||
const url = `${API_BASE}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
@@ -21,6 +82,14 @@ async function request<T>(
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
// 对非公开路径自动附加 access token
|
||||
if (!isPublicPath(path) && !headers["Authorization"]) {
|
||||
const token = authCallbacks?.getAccessToken();
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
const status = res.status;
|
||||
@@ -31,6 +100,14 @@ async function request<T>(
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
// 401 拦截:尝试刷新 token 后重试(仅重试一次)
|
||||
if (res.status === 401 && !_retry && !isPublicPath(path) && authCallbacks) {
|
||||
const refreshed = await refreshWithLock();
|
||||
if (refreshed) {
|
||||
return request<T>(path, options, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
error: { code: body.error || "UNKNOWN", message: body.message || "请求失败" },
|
||||
@@ -39,7 +116,7 @@ async function request<T>(
|
||||
}
|
||||
|
||||
return { data: body as T, status };
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return {
|
||||
error: { code: "NETWORK_ERROR", message: "网络连接失败,请检查网络" },
|
||||
status: 0,
|
||||
@@ -85,6 +162,33 @@ export async function login(
|
||||
});
|
||||
}
|
||||
|
||||
/** 内部用的 refresh 请求,不经过 401 拦截(避免递归)。 */
|
||||
async function refreshTokenDirect(
|
||||
refresh_token: string
|
||||
): Promise<ApiResponse<AuthResponse>> {
|
||||
const url = `${API_BASE}/auth/refresh`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token }),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok) {
|
||||
return {
|
||||
error: { code: body.error || "UNKNOWN", message: body.message || "请求失败" },
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
return { data: body as AuthResponse, status: res.status };
|
||||
} catch {
|
||||
return {
|
||||
error: { code: "NETWORK_ERROR", message: "网络连接失败" },
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshToken(
|
||||
refresh_token: string
|
||||
): Promise<ApiResponse<AuthResponse>> {
|
||||
@@ -96,11 +200,11 @@ export async function refreshToken(
|
||||
|
||||
export async function logout(
|
||||
accessToken: string,
|
||||
refreshToken: string
|
||||
refreshTokenStr: string
|
||||
): Promise<ApiResponse<{ message: string }>> {
|
||||
return request<{ message: string }>("/auth/logout", {
|
||||
method: "POST",
|
||||
headers: authHeaders(accessToken),
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
body: JSON.stringify({ refresh_token: refreshTokenStr }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "react";
|
||||
import * as api from "./api";
|
||||
import type { AuthUser } from "./api";
|
||||
import { setAuthCallbacks } from "./api";
|
||||
import {
|
||||
clearAuth,
|
||||
loadAccessToken,
|
||||
@@ -108,6 +109,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
[clearRefreshTimer, persistAuth]
|
||||
);
|
||||
|
||||
// 注册 API 层认证回调(用于 401 拦截器)
|
||||
useEffect(() => {
|
||||
setAuthCallbacks({
|
||||
getAccessToken: () => loadAccessToken(),
|
||||
getRefreshToken: () => loadRefreshToken(),
|
||||
onRefreshSuccess: (u, at, rt) => {
|
||||
persistAuth(u, at, rt);
|
||||
scheduleRefresh(at);
|
||||
},
|
||||
onRefreshFailed: () => {
|
||||
clearAuth();
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
},
|
||||
});
|
||||
}, [persistAuth, scheduleRefresh]);
|
||||
|
||||
// 初始化:检查已有 token 并尝试刷新
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
|
||||
Reference in New Issue
Block a user