Files
leaudit-platform-frontend/app/routes/callback.tsx
T

344 lines
14 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, getPortOAuthConfig } from "~/config/oauth-secret.server";
import { loginWithOAuth, type LoginRequest } from "~/api/login/login-client";
import { isMobileDevice, MOBILE_CHAT_PATH } from "~/utils/mobile-detect.server";
/**
* 端口号到地区的映射关系
* 根据 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回调参数验证通过");
// 🔑 判断是否从钉钉登录:检查 remote-user header
const remoteUser = request.headers.get("remote-user");
const isDingTalkLogin = remoteUser !== null;
console.log("🔧 [Callback] remote-user 检测:", {
remoteUser: remoteUser,
isDingTalkLogin: isDingTalkLogin,
loginType: isDingTalkLogin ? '钉钉Web登录' : '内网Web登录'
});
// 声明在 try 外部,以便在 catch 中访问
let tokenResponse = null;
// 获取端口特定的OAuth配置(包含钉钉回调地址)
const portOAuthConfig = getPortOAuthConfig(port);
const oauthClient = new OAuthClient(portOAuthConfig);
// 🔑 根据登录类型设置回调地址
if (isDingTalkLogin) {
// 钉钉登录:使用钉钉回调地址
if (portOAuthConfig.dingtalkRedirectUri) {
oauthClient.setRedirectUri(portOAuthConfig.dingtalkRedirectUri);
console.log("🔧 [Callback] 使用钉钉回调地址:", portOAuthConfig.dingtalkRedirectUri);
} else {
console.warn("⚠️ [Callback] 钉钉登录但未配置钉钉回调地址,使用默认内网地址");
}
} else {
// 内网登录:使用默认内网回调地址
console.log("🔧 [Callback] 使用内网回调地址:", portOAuthConfig.redirectUri);
}
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] 用户信息获取成功");
// 🔑 检测移动端设备,决定重定向目标
const isMobile = isMobileDevice(request);
console.log(`📱 [Callback] 设备类型检测: ${isMobile ? '移动端' : '桌面端'}`);
// 移动端用户直接跳转到对话页面,桌面端用户跳转到首页选择模块
const redirectTo = isMobile ? MOBILE_CHAT_PATH : "/";
// 调用后端登录接口,传递 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)
// 直接使用当前服务端时间作为 session 签发时间,避免后端返回的本地时间字符串被 Node 以不同时区解析
const tokenIssuedAt = Date.now();
// 更新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,
// 🔑 包含后端返回的组织信息字段(可能为null)
tenant_name: savedUserInfo.tenant_name,
dep_name: savedUserInfo.dep_name,
dep_short_name: savedUserInfo.dep_short_name,
};
// 🔑 重要:将 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,
// 🔑 包含后端返回的组织信息字段(可能为null)
tenant_name: savedUserInfo.tenant_name,
dep_name: savedUserInfo.dep_name,
dep_short_name: savedUserInfo.dep_short_name,
})));
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");
// 从 URL 参数中获取重定向目标(服务端已根据设备类型设置)
const redirectTo = searchParams.get("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>
);
}