Files
leaudit-platform-frontend/app/routes/callback.tsx
T
LiangShiyong d09d5b709d Merge branch 'PingChuan' into shiy-login
# Conflicts:
#	app/config/api-config.ts
fix: 1. 修复无法加载数据的问题:没有从入口页中进来会缺少数据。
2. 加强后端接口关于token的校验错误和权限校验错误的管理。

feat: 1. 对接后端的数据看板的接口。
2. 将系统设置单独抽出来作为管理员的固定一个入口。
2025-11-22 15:57:22 +08:00

316 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { useEffect } from "react";
import { useSearchParams } from "@remix-run/react";
import { createUserSession, sessionStorage } from "~/api/login/auth.server";
import { OAuthClient } from "~/api/login/oauth-client";
import { getServerOAuthConfigRuntime } from "~/config/oauth-secret.server";
import { loginWithOAuth, type LoginRequest } from "~/api/login/login-client";
/**
* 端口号到地区的映射关系
* 根据 ecosystem.config.cjs 配置文件
*/
const PORT_TO_AREA_MAP: Record<string, string> = {
'51703': '梅州',
'51704': '云浮',
'51705': '揭阳',
'51706': '潮州',
'51707': '省局'
};
/**
* 根据端口号获取地区
* @param port - 端口号
* @returns 地区名称,如果未找到则返回 undefined
*/
function getAreaByPort(port: string): string | undefined {
return PORT_TO_AREA_MAP[port];
}
/**
* 辅助函数:使用 session flash 重定向到登录页面并传递错误信息
*/
async function redirectToLoginWithError(request: Request, errorMessage: string) {
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
session.flash("loginError", errorMessage);
return redirect("/login", {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session)
}
});
}
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
// const origin = url.origin; // 获取请求的源 (e.g., "http://10.79.97.17:51703")
const port = url.port; // 获取端口号
const area = getAreaByPort(port); // 根据端口号获取地区
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
const error_description = url.searchParams.get("error_description");
// 🔑 检查是否是管理员账密登录(直接传递 token 和 userInfo
const token = url.searchParams.get("token");
const userInfo = url.searchParams.get("userInfo");
// const redirectTo = url.searchParams.get("redirectTo") || "/";
// 🔑 如果有 token 和 userInfo,说明是管理员账密登录
// login.tsx action 已经创建了 Cookie Session,这里只需要返回 null
// 让客户端组件从 URL 读取 token 并保存到 localStorage
if (token && userInfo) {
console.log("✅ [Callback] 检测到管理员账密登录,交给客户端处理");
// 验证 userInfo 格式(防止解析错误)
try {
JSON.parse(decodeURIComponent(userInfo));
} catch (error) {
console.error("❌ [Callback] 解析管理员登录用户信息失败:", error);
return redirectToLoginWithError(request, "登录失败:用户信息格式错误");
}
// ✅ 直接返回 null,让客户端 useEffect 处理
// 注意:Cookie Session 已经在 login.tsx action 中通过 createUserSession 创建
return null;
}
// console.log("🔧 OAuth2.0回调参数:", {
// code: code ? `${code.substring(0, 10)}...` : null,
// state: state,
// error: error,
// error_description: error_description,
// fullUrl: request.url,
// port: port,
// area: area
// });
// 检查是否有错误
if (error) {
console.error("❌ OAuth2.0授权失败:", error, error_description);
return redirectToLoginWithError(request, error_description || error || "授权失败");
}
// 检查是否有授权码
if (!code) {
console.error("❌ OAuth2.0回调缺少授权码");
return redirectToLoginWithError(request, "登录过程中缺少授权码,请重新登录");
}
// 验证状态值
if (!state || !state.endsWith("_idp")) {
console.error("❌ OAuth2.0状态值验证失败:", { state, expectedSuffix: "_idp" });
return redirectToLoginWithError(request, "登录状态验证失败,请重新登录");
}
console.log("✅ OAuth2.0回调参数验证通过");
// 声明在 try 外部,以便在 catch 中访问
let tokenResponse = null;
const oauthClient = new OAuthClient(getServerOAuthConfigRuntime());
try {
console.log("🔧 开始处理OAuth2.0回调");
// 开始获取访问令牌
tokenResponse = await oauthClient.getAccessToken(code);
if (!tokenResponse) {
console.error("获取访问令牌失败");
// 注意:此时还没有 access_token,无法调用 IDaaS 登出
// 只能重定向到登录页,让用户重新开始登录流程
return redirectToLoginWithError(request, "获取访问令牌失败,请重试");
}
console.log("✅ [Callback] 访问令牌获取成功");
const userInfo = await oauthClient.getUserInfo(tokenResponse.access_token);
if(!userInfo || !userInfo.success){
console.error('获取用户信息失败:',userInfo);
// 🔑 关键:此时 IDaaS 那边已经登录成功,但我们获取用户信息失败
// 需要调用 IDaaS 登出,清除 IDaaS 的登录状态,避免用户下次登录时出现问题
try {
const logoutUrl = `${url.protocol}//${url.host}/login`;
const logoutSuccess = await oauthClient.logout(tokenResponse.access_token, logoutUrl);
if (logoutSuccess) {
console.log("✅ [Callback] 已清除 IDaaS 登录状态");
} else {
console.warn("⚠️ [Callback] 清除 IDaaS 登录状态失败");
}
} catch (logoutError) {
console.error("❌ [Callback] 调用 IDaaS 登出时出错:", logoutError);
}
return redirectToLoginWithError(request, "获取用户信息失败,请重试");
}
console.log("✅ [Callback] 用户信息获取成功");
// 🔑 强制重定向到首页,确保用户选择入口模块并初始化 sessionStorage
// 忽略 redirect 参数,总是跳转到首页让用户选择模块
const redirectTo = "/";
// 调用后端登录接口,传递 OAuth 用户信息,获取 JWT token
const loginRequest: LoginRequest = {
userInfo: userInfo.data,
expiresIn: tokenResponse.expires_in,
area: area
};
const loginResponse = await loginWithOAuth(loginRequest);
if (!loginResponse.success || !loginResponse.data) {
console.error("❌ [Callback] 后端登录失败:", loginResponse.error);
// 🔑 登录失败,需要清除 IDaaS 登录状态
try {
const logoutUrl = `${url.protocol}//${url.host}/login`;
const logoutSuccess = await oauthClient.logout(tokenResponse.access_token, logoutUrl);
if (logoutSuccess) {
console.log("✅ [Callback] 已清除 IDaaS 登录状态(后端登录失败)");
} else {
console.warn("⚠️ [Callback] 清除 IDaaS 登录状态失败(后端登录失败)");
}
} catch (logoutError) {
console.error("❌ [Callback] 调用 IDaaS 登出时出错(后端登录失败):", logoutError);
}
return redirectToLoginWithError(request, loginResponse.error || "登录失败,请重新登录");
}
console.log("✅ [Callback] 后端登录成功,JWT token 已获取");
const frontendJWT = loginResponse.data.access_token;
const savedUserInfo = loginResponse.data.user_info;
const backExpiresIn = loginResponse.data.expires_in || (60 * 60 * 8)
// 🔑 提取后端返回的签发时间并转换为时间戳
let tokenIssuedAt = Date.now(); // 默认使用当前时间
if (loginResponse.data.issued_time) {
try {
// 后端返回格式:"2025-11-18 17:41:06"
// 转换为时间戳(毫秒)
tokenIssuedAt = new Date(loginResponse.data.issued_time.replace(' ', 'T')).getTime();
console.log("📅 [Callback] 使用后端返回的签发时间:", loginResponse.data.issued_time, "→", tokenIssuedAt);
} catch (error) {
console.warn("⚠️ [Callback] 无法解析 issued_time,使用当前时间:", error);
}
}
// 更新userInfo以包含数据库ID、JWTuser_role 从后端返回)
const enhancedUserInfo = {
...userInfo.data, // 保留OAuth返回的原字段(包含 nickname 等)
username: savedUserInfo.username,
nick_name: savedUserInfo.nick_name,
phone_number: savedUserInfo.phone_number,
email: savedUserInfo.email,
ou_id: savedUserInfo.ou_id,
ou_name: savedUserInfo.ou_name,
is_leader: savedUserInfo.is_leader,
user_id: savedUserInfo.user_id,
user_role: savedUserInfo.user_role, // 使用后端返回的角色
area: savedUserInfo.area, // 🔑 用户所属地区
frontend_jwt: frontendJWT
};
// 🔑 重要:将 token 和用户信息作为 URL 参数传递给客户端
// 客户端 useEffect 会将其保存到 localStorage
const callbackUrl = new URL('/callback', url.origin);
callbackUrl.searchParams.set('token', frontendJWT);
callbackUrl.searchParams.set('userInfo', encodeURIComponent(JSON.stringify({
user_id: savedUserInfo.user_id,
username: savedUserInfo.username,
nick_name: savedUserInfo.nick_name,
email: savedUserInfo.email,
phone_number: savedUserInfo.phone_number,
ou_id: savedUserInfo.ou_id,
ou_name: savedUserInfo.ou_name,
is_leader: savedUserInfo.is_leader,
user_role: savedUserInfo.user_role,
area: savedUserInfo.area, // 🔑 用户所属地区
sub: userInfo.data.sub
})));
callbackUrl.searchParams.set('redirectTo', redirectTo);
// 使用统一的session创建函数
return createUserSession({
isAuthenticated: true,
userRole: savedUserInfo.user_role, // 使用后端返回的角色
redirectTo: callbackUrl.toString(), // 先跳转到 callback 页面保存 token
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
tokenExpiresIn: backExpiresIn,
tokenIssuedAt: tokenIssuedAt, // 🔑 传递后端返回的签发时间
userInfo: enhancedUserInfo,
frontendJWT
});
} catch (error) {
console.error("OAuth2.0回调处理失败:", error);
// 🔑 如果已经获取到了 access_token,需要清除 IDaaS 登录状态
if (tokenResponse?.access_token) {
try {
const logoutUrl = `${url.protocol}//${url.host}/login`;
const logoutSuccess = await oauthClient.logout(tokenResponse.access_token, logoutUrl);
if (logoutSuccess) {
console.log("✅ [Callback] 已清除 IDaaS 登录状态(回调处理异常)");
} else {
console.warn("⚠️ [Callback] 清除 IDaaS 登录状态失败(回调处理异常)");
}
} catch (logoutError) {
console.error("❌ [Callback] 调用 IDaaS 登出时出错(回调处理异常):", logoutError);
}
}
return redirectToLoginWithError(request, "登录回调处理失败,请重新登录");
}
}
export default function Callback() {
const [searchParams] = useSearchParams();
useEffect(() => {
// 从 URL 参数中获取 token(如果有)
const token = searchParams.get("token");
const userInfo = searchParams.get("userInfo");
// 🔑 强制重定向到首页,确保用户选择入口模块并初始化 sessionStorage
const redirectTo = "/";
if (token && typeof window !== 'undefined') {
console.log('🔑 [Callback] 开始保存 token 到 localStorage');
// 存储 token 到 localStorage
localStorage.setItem('access_token', token);
console.log('✅ [Callback] Token 已存储到 localStorage');
// 存储用户信息
if (userInfo) {
try {
const parsedUserInfo = JSON.parse(decodeURIComponent(userInfo));
localStorage.setItem('user_info', JSON.stringify(parsedUserInfo));
console.log('✅ [Callback] 用户信息已存储到 localStorage:', parsedUserInfo);
} catch (error) {
console.error('❌ [Callback] 解析用户信息失败:', error);
}
}
// ⏱️ 短暂延迟后跳转,确保 localStorage 写入完成
// 使用 requestAnimationFrame 确保 DOM 更新后立即跳转
requestAnimationFrame(() => {
setTimeout(() => {
console.log(`🚀 [Callback] 跳转到目标页面: ${redirectTo}`);
window.location.href = redirectTo;
}, 100); // 减少延迟从 500ms 到 100ms
});
}
}, [searchParams]);
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600">...</p>
<p className="mt-2 text-sm text-gray-500">...</p>
</div>
</div>
);
}