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 = { '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、JWT(user_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 (

正在处理登录...

即将跳转...

); }