feat: 1. 修改完善全局路由检测。 2. 完善统一的token认证管理,token失效自动跳转到登录页。
This commit is contained in:
+69
-69
@@ -488,9 +488,10 @@ const FALLBACK_MENU_DATA: Record<string, MenuItem[]> = {
|
||||
* 根据角色获取用户可访问的路由(调用后端统一接口)
|
||||
* @param roleKey 角色标识 (如: 'admin', 'common', 'deptLeader', 'groupLeader') - 暂时不使用,后端通过JWT自动识别
|
||||
* @param jwt JWT token
|
||||
* @param includeHidden 是否包含隐藏路由(默认 false)。true: 用于权限校验,false: 用于菜单渲染
|
||||
* @returns 用户可访问的路由列表
|
||||
*/
|
||||
export async function getUserRoutesByRole(roleKey: string, jwt?: string): Promise<{ success: boolean; data?: MenuItem[]; error?: string; shouldRedirectToHome?: boolean }> {
|
||||
export async function getUserRoutesByRole(roleKey: string, jwt?: string, includeHidden: boolean = false): Promise<{ success: boolean; data?: MenuItem[]; error?: string; shouldRedirectToHome?: boolean }> {
|
||||
try {
|
||||
console.log(`🔍 [User Routes] 获取用户路由,角色: ${roleKey}`);
|
||||
|
||||
@@ -553,10 +554,12 @@ export async function getUserRoutesByRole(roleKey: string, jwt?: string): Promis
|
||||
return { success: false, error: "用户没有分配任何路由权限", shouldRedirectToHome: true };
|
||||
}
|
||||
|
||||
// 将后端路由格式转换为前端 MenuItem 格式
|
||||
const menuItems = convertBackendRoutesToMenuItems(routes);
|
||||
// console.log('📋 [User Routes] 菜单数据:', routes);
|
||||
|
||||
console.log(`✅ [User Routes] 成功获取 ${menuItems.length} 个路由`);
|
||||
// 将后端路由格式转换为前端 MenuItem 格式
|
||||
const menuItems = convertBackendRoutesToMenuItems(routes, includeHidden);
|
||||
|
||||
// console.log(`✅ [User Routes] 成功获取 ${menuItems.length} 个路由 (includeHidden: ${includeHidden})`);
|
||||
// console.log('📋 [User Routes] 菜单数据:', menuItems);
|
||||
|
||||
return { success: true, data: menuItems };
|
||||
@@ -605,14 +608,70 @@ function convertIcon(elementIcon: string | null): string {
|
||||
return ICON_MAPPING[elementIcon] || 'ri-file-line';
|
||||
}
|
||||
|
||||
/**
|
||||
* 将平铺的路由数组构建为树形结构
|
||||
* @param routes 平铺的路由数组
|
||||
* @returns 树形结构的路由数组
|
||||
*/
|
||||
function buildRouteTree(routes: BackendRouteInfo[]): BackendRouteInfo[] {
|
||||
// 创建路由映射
|
||||
const routeMap = new Map<number, BackendRouteInfo>();
|
||||
const rootRoutes: BackendRouteInfo[] = [];
|
||||
|
||||
// 第一遍:创建所有路由的映射,并初始化 children 数组
|
||||
routes.forEach(route => {
|
||||
routeMap.set(route.id, { ...route, children: [] });
|
||||
});
|
||||
|
||||
// 第二遍:构建父子关系
|
||||
routes.forEach(route => {
|
||||
const currentRoute = routeMap.get(route.id);
|
||||
if (!currentRoute) return;
|
||||
|
||||
if (route.parent_id === null || route.parent_id === 0) {
|
||||
// 顶级路由
|
||||
rootRoutes.push(currentRoute);
|
||||
} else {
|
||||
// 子路由,添加到父路由的 children 中
|
||||
const parentRoute = routeMap.get(route.parent_id);
|
||||
if (parentRoute) {
|
||||
if (!parentRoute.children) {
|
||||
parentRoute.children = [];
|
||||
}
|
||||
parentRoute.children.push(currentRoute);
|
||||
} else {
|
||||
// 如果找不到父路由,当作顶级路由处理
|
||||
// console.warn(`⚠️ [User Routes] 找不到父路由 (parent_id: ${route.parent_id}) for route: ${route.route_name}`);
|
||||
rootRoutes.push(currentRoute);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return rootRoutes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 将后端路由格式转换为前端 MenuItem 格式
|
||||
* @param backendRoutes 后端返回的路由数组
|
||||
* @param backendRoutes 后端返回的路由数组(可能是树形或平铺)
|
||||
* @param includeHidden 是否包含隐藏路由(默认 false)。true: 用于权限校验,false: 用于菜单渲染
|
||||
* @returns MenuItem 数组
|
||||
*/
|
||||
function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[]): MenuItem[] {
|
||||
return backendRoutes
|
||||
.filter(route => !route.is_hidden) // 过滤隐藏的路由
|
||||
function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[], includeHidden: boolean = false): MenuItem[] {
|
||||
// 检查是否需要构建树形结构
|
||||
// 如果存在 parent_id 不为 null/0 但没有对应的父路由在 children 中,说明是平铺数组
|
||||
const needsBuildTree = backendRoutes.some(route =>
|
||||
route.parent_id !== null &&
|
||||
route.parent_id !== 0 &&
|
||||
!backendRoutes.some(r => r.children?.some(c => c.id === route.id))
|
||||
);
|
||||
|
||||
// 如果是平铺数组,先构建树形结构
|
||||
const treeRoutes = needsBuildTree ? buildRouteTree(backendRoutes) : backendRoutes;
|
||||
|
||||
return treeRoutes
|
||||
.filter(route => includeHidden || !route.is_hidden) // 根据 includeHidden 决定是否过滤隐藏路由
|
||||
.map(route => {
|
||||
const menuItem: MenuItem = {
|
||||
id: route.route_name || `route-${route.id}`,
|
||||
@@ -623,9 +682,9 @@ function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[]): Men
|
||||
hideBreadcrumb: route.is_hidden
|
||||
};
|
||||
|
||||
// 递归处理子路由
|
||||
// 递归处理子路由,传递 includeHidden 参数
|
||||
if (route.children && route.children.length > 0) {
|
||||
menuItem.children = convertBackendRoutesToMenuItems(route.children);
|
||||
menuItem.children = convertBackendRoutesToMenuItems(route.children, includeHidden);
|
||||
}
|
||||
|
||||
return menuItem;
|
||||
@@ -633,65 +692,6 @@ function convertBackendRoutesToMenuItems(backendRoutes: BackendRouteInfo[]): Men
|
||||
.sort((a, b) => a.order - b.order); // 按 sort_order 排序
|
||||
}
|
||||
|
||||
/**
|
||||
* 从路由信息构建菜单树结构(旧版本,已废弃)
|
||||
* @param routes 路由信息数组
|
||||
* @returns 菜单树结构
|
||||
* @deprecated 使用 convertBackendRoutesToMenuItems 替代
|
||||
*/
|
||||
function buildMenuTreeFromRoutes(routes: RouteInfo[]): MenuItem[] {
|
||||
// 转换为MenuItem格式
|
||||
const menuMap = new Map<number, MenuItem>();
|
||||
|
||||
routes.forEach(route => {
|
||||
const menuItem: MenuItem = {
|
||||
id: route.name,
|
||||
title: route.meta.title,
|
||||
path: route.path,
|
||||
icon: route.meta.icon,
|
||||
order: route.meta.order || 0,
|
||||
requiredRole: route.meta.requiredRole
|
||||
};
|
||||
|
||||
menuMap.set(route.id, menuItem);
|
||||
});
|
||||
|
||||
// 构建父子关系
|
||||
const rootItems: MenuItem[] = [];
|
||||
const itemsWithParent: Array<{ item: MenuItem; parentId: number }> = [];
|
||||
|
||||
routes.forEach(route => {
|
||||
const menuItem = menuMap.get(route.id);
|
||||
if (!menuItem) return;
|
||||
|
||||
if (route.parent_id === 0) {
|
||||
rootItems.push(menuItem);
|
||||
} else {
|
||||
itemsWithParent.push({ item: menuItem, parentId: route.parent_id });
|
||||
}
|
||||
});
|
||||
|
||||
// 添加子菜单
|
||||
itemsWithParent.forEach(({ item, parentId }) => {
|
||||
const parent = menuMap.get(parentId);
|
||||
if (parent) {
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
parent.children.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// 排序
|
||||
rootItems.sort((a, b) => a.order - b.order);
|
||||
rootItems.forEach(item => {
|
||||
if (item.children) {
|
||||
item.children.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
});
|
||||
|
||||
return rootItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户角色映射到权限系统的角色标识
|
||||
|
||||
+75
-134
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
|
||||
import { createCookieSessionStorage } from "@remix-run/node";
|
||||
import { tokenManager } from "./token-manager.server";
|
||||
import { postgrestGet, postgrestPost, postgrestPut } from "../postgrest-client";
|
||||
import { JWTUtils, type UserInfoForJWT } from "~/utils/jwt";
|
||||
import { OAUTH_CONFIG, API_BASE_URL } from "~/config/api-config";
|
||||
@@ -49,6 +48,8 @@ export interface UserInfo {
|
||||
is_leader?: boolean; // 是否为部门负责人
|
||||
area?: string; // 用户所属地区
|
||||
id?: string | number; // 临时的用户id
|
||||
user_id?: string | number;
|
||||
user_role?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,150 +199,80 @@ export async function getUserSession(request: Request) {
|
||||
const userInfo = session.get("userInfo");
|
||||
let frontendJWT = session.get("frontendJWT");
|
||||
|
||||
let isTokenExpired = false;
|
||||
let refreshedSession = null;
|
||||
let shouldRegenerateJWT = false;
|
||||
|
||||
// 🔑 admin 用户不需要刷新 OAuth token,只需要维护 JWT
|
||||
const isAdmin = userRole === 'admin';
|
||||
|
||||
// 如果有token信息,检查是否需要刷新(admin用户跳过OAuth token刷新)
|
||||
if (!isAdmin && accessToken && refreshToken && tokenIssuedAt && tokenExpiresIn) {
|
||||
try {
|
||||
const tokenInfo = {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenIssuedAt,
|
||||
tokenExpiresIn
|
||||
};
|
||||
|
||||
// 检查并自动刷新token
|
||||
const refreshResult = await tokenManager.checkAndRefreshToken(tokenInfo);
|
||||
|
||||
if (refreshResult.success && refreshResult.newTokenInfo) {
|
||||
const newToken = refreshResult.newTokenInfo;
|
||||
|
||||
// 如果token被刷新了,更新session
|
||||
if (newToken.accessToken !== accessToken) {
|
||||
console.log("Token已刷新,更新session");
|
||||
|
||||
session.set("accessToken", newToken.accessToken);
|
||||
session.set("refreshToken", newToken.refreshToken);
|
||||
session.set("tokenIssuedAt", newToken.tokenIssuedAt);
|
||||
session.set("tokenExpiresIn", newToken.tokenExpiresIn);
|
||||
|
||||
// 更新本地变量
|
||||
accessToken = newToken.accessToken;
|
||||
tokenIssuedAt = newToken.tokenIssuedAt;
|
||||
tokenExpiresIn = newToken.tokenExpiresIn;
|
||||
|
||||
// 标记需要重新生成JWT
|
||||
shouldRegenerateJWT = true;
|
||||
|
||||
refreshedSession = session;
|
||||
}
|
||||
|
||||
isTokenExpired = false;
|
||||
} else {
|
||||
console.error("Token刷新失败:", refreshResult.error);
|
||||
isTokenExpired = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Token验证过程中出错:", error);
|
||||
isTokenExpired = true;
|
||||
}
|
||||
} else if (isAdmin) {
|
||||
// admin 用户:不检查 OAuth token 过期,始终保持登录状态
|
||||
// console.log("admin 用户登录,跳过 OAuth token 刷新");
|
||||
isTokenExpired = false;
|
||||
|
||||
// admin 用户需要有一个合理的 tokenExpiresIn 用于 JWT 生成
|
||||
// 如果没有设置,使用一个默认值(如2小时)
|
||||
if (!tokenExpiresIn) {
|
||||
tokenExpiresIn = 7200; // 2小时
|
||||
session.set("tokenExpiresIn", tokenExpiresIn);
|
||||
refreshedSession = session;
|
||||
}
|
||||
// 🔑 新的统一过期检查逻辑
|
||||
// 不区分 admin 和 OAuth 用户,所有用户都使用同样的过期检查
|
||||
// 如果没有设置 tokenExpiresIn,给一个默认值(24小时)
|
||||
if (!tokenExpiresIn) {
|
||||
tokenExpiresIn = 86400; // 24小时(与后端登录接口返回的一致)
|
||||
session.set("tokenExpiresIn", tokenExpiresIn);
|
||||
refreshedSession = session;
|
||||
}
|
||||
|
||||
// 检查前端JWT状态
|
||||
if (isAuthenticated && !isTokenExpired && userInfo) {
|
||||
let needsJWTRefresh = false;
|
||||
|
||||
// 检查是否有前端JWT
|
||||
// 🔑 简化:不再自动重新生成 JWT
|
||||
// JWT 即将过期时,直接让用户重新登录
|
||||
|
||||
// 🔑 关键:验证 session 完整性
|
||||
// 如果 isAuthenticated 为 true,但缺少关键数据(userInfo),则认为 session 无效
|
||||
let finalIsAuthenticated = isAuthenticated;
|
||||
|
||||
if (finalIsAuthenticated) {
|
||||
// 检查是否有用户信息
|
||||
if (!userInfo) {
|
||||
console.warn("⚠️ [getUserSession] Session 不完整:缺少 userInfo");
|
||||
finalIsAuthenticated = false;
|
||||
}
|
||||
|
||||
// 检查是否有前端 JWT
|
||||
if (!frontendJWT) {
|
||||
needsJWTRefresh = true;
|
||||
console.log("缺少前端JWT,需要生成");
|
||||
} else {
|
||||
// 检查JWT是否即将过期
|
||||
if (JWTUtils.isJWTExpiringSoon(frontendJWT)) {
|
||||
needsJWTRefresh = true;
|
||||
console.log("前端JWT即将过期,需要重新生成");
|
||||
}
|
||||
console.warn("⚠️ [getUserSession] Session 不完整:缺少 frontendJWT");
|
||||
finalIsAuthenticated = false;
|
||||
}
|
||||
|
||||
// 如果OAuth token被刷新了,也需要重新生成JWT
|
||||
if (shouldRegenerateJWT) {
|
||||
needsJWTRefresh = true;
|
||||
console.log("OAuth token已刷新,需要重新生成JWT");
|
||||
}
|
||||
|
||||
// 重新生成JWT
|
||||
if (needsJWTRefresh && tokenExpiresIn) {
|
||||
try {
|
||||
// 从userInfo中获取用户数据
|
||||
if (userInfo.user_id && userInfo.sub) {
|
||||
const mockSavedUserData: SsoUser = {
|
||||
id: userInfo.user_id,
|
||||
sub: userInfo.sub,
|
||||
username: userInfo.username || userInfo.sub,
|
||||
nick_name: userInfo.nick_name || "未知用户",
|
||||
phone_number: userInfo.phone_number,
|
||||
email: userInfo.email,
|
||||
ou_id: userInfo.ou_id || "default",
|
||||
ou_name: userInfo.ou_name || "未知部门",
|
||||
status: 0,
|
||||
is_leader: userInfo.is_leader || false
|
||||
};
|
||||
|
||||
const newJWT = await generateFrontendJWT(userInfo, mockSavedUserData, userRole, tokenExpiresIn);
|
||||
|
||||
// 打印JWT重新生成信息
|
||||
console.log("=== Token刷新时重新生成JWT ===");
|
||||
// console.log("原始userInfo:", userInfo);
|
||||
// console.log("重构的用户数据:", mockSavedUserData);
|
||||
// console.log("用户角色:", userRole);
|
||||
// console.log("新生成的JWT:", newJWT);
|
||||
// console.log("JWT过期时间:", JWTUtils.getJWTExpiration(newJWT));
|
||||
|
||||
// 更新session中的JWT
|
||||
if (!refreshedSession) {
|
||||
refreshedSession = session;
|
||||
}
|
||||
refreshedSession.set("frontendJWT", newJWT);
|
||||
|
||||
// 更新userInfo以包含新的JWT
|
||||
const updatedUserInfo = createUserInfoWithJWT(userInfo, mockSavedUserData, userRole, newJWT);
|
||||
refreshedSession.set("userInfo", updatedUserInfo);
|
||||
|
||||
console.log("更新后的userInfo:", updatedUserInfo);
|
||||
console.log("=== JWT重新生成完成 ===");
|
||||
|
||||
frontendJWT = newJWT;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("生成前端JWT失败:", error);
|
||||
|
||||
// 🔑 统一的 token 过期检查(所有用户类型)
|
||||
// 使用签发时间 + 有效期 - 5分钟缓冲来判断是否需要重新登录
|
||||
if (tokenIssuedAt && tokenExpiresIn) {
|
||||
const now = Date.now(); // 当前时间(毫秒)
|
||||
const expiresAt = tokenIssuedAt + (tokenExpiresIn * 1000); // 过期时间(毫秒)
|
||||
const bufferTime = 5 * 60 * 1000; // 5分钟缓冲时间(毫秒)
|
||||
|
||||
// 如果当前时间 >= 过期时间 - 5分钟,则认为 token 即将过期或已过期
|
||||
if (now >= expiresAt - bufferTime) {
|
||||
const remainingSeconds = Math.floor((expiresAt - now) / 1000);
|
||||
console.error(`❌ [getUserSession] Token 即将过期或已过期 (剩余: ${remainingSeconds} 秒)`);
|
||||
finalIsAuthenticated = false;
|
||||
}
|
||||
} else if (!tokenIssuedAt) {
|
||||
// 如果没有签发时间,认为 session 无效(可能是旧版本数据)
|
||||
console.warn("⚠️ [getUserSession] Session 缺少 tokenIssuedAt,认为已过期");
|
||||
finalIsAuthenticated = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 🚨 如果 session 无效(包括 token 过期),自动重定向到登录页
|
||||
if (!finalIsAuthenticated && isAuthenticated) {
|
||||
console.error("❌ [getUserSession] Session 已失效,清除 session 并重定向到登录页");
|
||||
|
||||
// 销毁服务端 session
|
||||
const { redirect } = await import("@remix-run/node");
|
||||
const destroyedSession = await sessionStorage.destroySession(session);
|
||||
|
||||
// 重定向到登录页,添加 expired=true 参数标识是因为过期重定向
|
||||
throw redirect("/login?expired=true", {
|
||||
headers: {
|
||||
"Set-Cookie": destroyedSession
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isAuthenticated: isAuthenticated && !isTokenExpired,
|
||||
isAuthenticated: finalIsAuthenticated,
|
||||
userRole,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
userInfo,
|
||||
isTokenExpired,
|
||||
refreshedSession, // 如果刷新了token,返回更新后的session
|
||||
frontendJWT // 返回前端JWT
|
||||
};
|
||||
@@ -370,27 +301,37 @@ export async function createUserSession(params: {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
tokenExpiresIn?: number;
|
||||
tokenIssuedAt?: number; // 🔑 新增:支持传递签发时间
|
||||
userInfo?: UserInfo;
|
||||
frontendJWT?: string;
|
||||
}) {
|
||||
const session = await sessionStorage.getSession();
|
||||
|
||||
|
||||
// 基础认证信息
|
||||
session.set("isAuthenticated", params.isAuthenticated);
|
||||
session.set("userRole", params.userRole);
|
||||
|
||||
|
||||
// OAuth token信息
|
||||
if (params.accessToken) {
|
||||
session.set("accessToken", params.accessToken);
|
||||
session.set("tokenIssuedAt", Date.now());
|
||||
}
|
||||
if (params.refreshToken) {
|
||||
session.set("refreshToken", params.refreshToken);
|
||||
}
|
||||
|
||||
// 🔑 Token 时间信息(与 accessToken 独立,支持管理员登录)
|
||||
// 只要有 tokenExpiresIn 或 tokenIssuedAt,就设置这些字段
|
||||
if (params.tokenExpiresIn) {
|
||||
session.set("tokenExpiresIn", params.tokenExpiresIn);
|
||||
}
|
||||
|
||||
if (params.tokenIssuedAt !== undefined) {
|
||||
// 优先使用传递的 tokenIssuedAt
|
||||
session.set("tokenIssuedAt", params.tokenIssuedAt);
|
||||
} else if (params.accessToken || params.frontendJWT) {
|
||||
// 如果没有传递 tokenIssuedAt,但有 token(accessToken 或 frontendJWT),则使用当前时间
|
||||
session.set("tokenIssuedAt", Date.now());
|
||||
}
|
||||
|
||||
// 用户信息和JWT
|
||||
if (params.userInfo) {
|
||||
session.set("userInfo", params.userInfo);
|
||||
|
||||
@@ -33,8 +33,9 @@ export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
issued_time: string; // 🔑 后端返回的签发时间,格式:"2025-11-18 17:41:06"
|
||||
user_info: {
|
||||
user_id: string;
|
||||
user_id?: string;
|
||||
username: string;
|
||||
nick_name: string;
|
||||
email?: string;
|
||||
|
||||
Reference in New Issue
Block a user