// 인증 컨트롤러 // 기존 Java ApiLoginController를 Node.js로 포팅 import { Request, Response } from "express"; import { AuthService } from "../services/authService"; import { JwtUtils } from "../utils/jwtUtils"; import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth"; import { logger } from "../utils/logger"; import { sendSmartFactoryLog } from "../utils/smartFactoryLog"; import { query, queryOne } from "../database/db"; export class AuthController { /** * POST /api/auth/login * 기존 Java ApiLoginController.login() 메서드 포팅 */ static async login(req: Request, res: Response): Promise { try { const { userId, password }: LoginRequest = req.body; const remoteAddr = req.ip || req.connection.remoteAddress || "unknown"; logger.debug(`로그인 요청: ${userId}`); // 입력값 검증 if (!userId || !password) { res.status(400).json({ success: false, message: "사용자 ID와 비밀번호를 입력해주세요.", error: { code: "INVALID_INPUT", details: "필수 입력값이 누락되었습니다.", }, }); return; } // 로그인 프로세스 실행 const loginResult = await AuthService.processLogin( userId, password, remoteAddr ); if (loginResult.success && loginResult.userInfo && loginResult.token) { // 로그인 성공 const userInfo: UserInfo = { userId: loginResult.userInfo.userId, userName: loginResult.userInfo.userName || "", deptName: loginResult.userInfo.deptName || "", companyCode: loginResult.userInfo.companyCode || "ILSHIN", }; logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`); // 메뉴 조회를 위한 공통 파라미터 const { AdminService } = await import("../services/adminService"); const paramMap = { userId: loginResult.userInfo.userId, userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN", userType: loginResult.userInfo.userType, userLang: "ko", }; // 사용자의 첫 번째 접근 가능한 메뉴 조회 let firstMenuPath: string | null = null; let firstMenuName: string | null = null; try { const menuList = await AdminService.getUserMenuList(paramMap); logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); const firstMenu = menuList.find((menu: any) => { const level = menu.lev || menu.level; const url = menu.menu_url || menu.url; return level >= 2 && url && url.trim() !== "" && url !== "#"; }); if (firstMenu) { firstMenuPath = firstMenu.menu_url || firstMenu.url; firstMenuName = firstMenu.menu_name_kor || firstMenu.translated_name || firstMenu.menu_name || null; logger.debug(`첫 번째 메뉴: ${firstMenuPath} (${firstMenuName})`); } else { logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동"); } } catch (menuError) { logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError); } // 스마트공장 활용 로그 전송 (비동기, 응답 블로킹 안 함) sendSmartFactoryLog({ userId: userInfo.userId, userName: userInfo.userName, remoteAddr, useType: "접속", companyCode: userInfo.companyCode, }).catch(() => {}); // POP 랜딩 경로 조회 let popLandingPath: string | null = null; try { const popResult = await AdminService.getPopMenuList(paramMap); if (popResult.landingMenu?.menu_url) { popLandingPath = popResult.landingMenu.menu_url; } else if (popResult.childMenus.length === 1) { popLandingPath = popResult.childMenus[0].menu_url; } else if (popResult.childMenus.length > 1) { popLandingPath = "/pop"; } logger.debug(`POP 랜딩 경로: ${popLandingPath}`); // POP 메뉴가 존재하면 해당 회사의 POP 레이아웃 자동 초기화 (비동기) if (popLandingPath) { const companyCode = loginResult.userInfo.companyCode || "ILSHIN"; AuthController.initPopLayoutsForCompany(companyCode).catch((err) => { logger.warn("POP 레이아웃 자동 초기화 중 오류 (무시):", err); }); } } catch (popError) { logger.warn("POP 메뉴 조회 중 오류 (무시):", popError); } res.status(200).json({ success: true, message: "로그인 성공", data: { userInfo, token: loginResult.token, firstMenuPath, firstMenuName, popLandingPath, }, }); } else { // 로그인 실패 res.status(401).json({ success: false, message: "로그인 실패", error: { code: "LOGIN_FAILED", details: loginResult.errorReason || "알 수 없는 오류가 발생했습니다.", }, }); } } catch (error) { logger.error( `로그인 API 오류: ${error instanceof Error ? error.message : error}` ); res.status(500).json({ success: false, message: "서버 오류가 발생했습니다.", error: { code: "SERVER_ERROR", details: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", }, }); } } /** * POST /api/auth/switch-company * WACE 관리자 전용: 다른 회사로 전환 */ static async switchCompany(req: Request, res: Response): Promise { try { const { companyCode } = req.body; const authHeader = req.get("Authorization"); const token = authHeader && authHeader.split(" ")[1]; if (!token) { res.status(401).json({ success: false, message: "인증 토큰이 필요합니다.", error: { code: "TOKEN_MISSING" }, }); return; } // 현재 사용자 정보 확인 const currentUser = JwtUtils.verifyToken(token); // WACE 관리자 권한 체크 (userType = "SUPER_ADMIN"만 확인) // 이미 다른 회사로 전환한 상태(companyCode != "*")에서도 다시 전환 가능해야 함 if (currentUser.userType !== "SUPER_ADMIN") { logger.warn(`회사 전환 권한 없음: userId=${currentUser.userId}, userType=${currentUser.userType}, companyCode=${currentUser.companyCode}`); res.status(403).json({ success: false, message: "회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.", error: { code: "FORBIDDEN" }, }); return; } // 전환할 회사 코드 검증 if (!companyCode || companyCode.trim() === "") { res.status(400).json({ success: false, message: "전환할 회사 코드가 필요합니다.", error: { code: "INVALID_INPUT" }, }); return; } logger.info(`=== WACE 관리자 회사 전환 ===`, { userId: currentUser.userId, originalCompanyCode: currentUser.companyCode, targetCompanyCode: companyCode, }); // 회사 코드 존재 여부 확인 (company_code가 "*"가 아닌 경우만) if (companyCode !== "*") { const { query } = await import("../database/db"); const companies = await query( "SELECT company_code, company_name FROM company_mng WHERE company_code = $1", [companyCode] ); if (companies.length === 0) { res.status(404).json({ success: false, message: "존재하지 않는 회사 코드입니다.", error: { code: "COMPANY_NOT_FOUND" }, }); return; } } // 새로운 JWT 토큰 발급 (company_code만 변경) const newPersonBean: PersonBean = { ...currentUser, companyCode: companyCode.trim(), // 전환할 회사 코드로 변경 }; const newToken = JwtUtils.generateToken(newPersonBean); logger.info(`✅ 회사 전환 성공: ${currentUser.userId} → ${companyCode}`); res.status(200).json({ success: true, message: "회사 전환 완료", data: { token: newToken, companyCode: companyCode.trim(), }, }); } catch (error) { logger.error( `회사 전환 API 오류: ${error instanceof Error ? error.message : error}` ); res.status(500).json({ success: false, message: "회사 전환 중 오류가 발생했습니다.", error: { code: "SERVER_ERROR", details: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", }, }); } } /** * POST /api/auth/logout * 기존 Java ApiLoginController.logout() 메서드 포팅 */ static async logout(req: Request, res: Response): Promise { try { const remoteAddr = req.ip || req.connection.remoteAddress || "unknown"; // JWT 토큰에서 사용자 정보 추출 const authHeader = req.get("Authorization"); const token = authHeader && authHeader.split(" ")[1]; if (token) { try { const userInfo = JwtUtils.verifyToken(token); await AuthService.processLogout(userInfo.userId, remoteAddr); } catch (tokenError) { logger.warn( `로그아웃 시 토큰 검증 실패: ${tokenError instanceof Error ? tokenError.message : tokenError}` ); } } res.status(200).json({ success: true, message: "로그아웃되었습니다.", data: null, }); } catch (error) { logger.error( `로그아웃 API 오류: ${error instanceof Error ? error.message : error}` ); res.status(500).json({ success: false, message: "로그아웃 처리 중 오류가 발생했습니다.", error: { code: "LOGOUT_ERROR", details: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", }, }); } } /** * GET /api/auth/me * 기존 Java ApiLoginController.getCurrentUser() 메서드 포팅 */ static async getCurrentUser(req: Request, res: Response): Promise { try { const authHeader = req.get("Authorization"); const token = authHeader && authHeader.split(" ")[1]; if (!token) { res.status(401).json({ success: false, message: "인증되지 않은 사용자입니다.", error: { code: "NOT_AUTHENTICATED", details: "세션이 만료되었거나 로그인이 필요합니다.", }, }); return; } const userInfo = JwtUtils.verifyToken(token); // DB에서 최신 사용자 정보 조회 (locale 포함) const dbUserInfo = await AuthService.getUserInfo(userInfo.userId); if (!dbUserInfo) { res.status(401).json({ success: false, message: "사용자 정보를 찾을 수 없습니다.", error: { code: "USER_NOT_FOUND", details: "사용자 정보가 삭제되었거나 존재하지 않습니다.", }, }); return; } // 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환 // ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원) const userInfoResponse: any = { userId: dbUserInfo.userId, userName: dbUserInfo.userName || "", deptName: dbUserInfo.deptName || "", companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선 company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선 userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선 userTypeName: dbUserInfo.userTypeName || "일반사용자", email: dbUserInfo.email || "", photo: dbUserInfo.photo, locale: dbUserInfo.locale || "KR", // locale 정보 추가 deptCode: dbUserInfo.deptCode, // 추가 필드 isAdmin: dbUserInfo.userType === "ADMIN" || dbUserInfo.userId === "plm_admin", }; res.status(200).json({ success: true, message: "사용자 정보 조회 성공", data: userInfoResponse, }); } catch (error) { logger.error( `사용자 정보 조회 API 오류: ${error instanceof Error ? error.message : error}` ); res.status(401).json({ success: false, message: "인증되지 않은 사용자입니다.", error: { code: "NOT_AUTHENTICATED", details: "세션이 만료되었거나 로그인이 필요합니다.", }, }); } } /** * GET /api/auth/status * 기존 Java ApiLoginController.checkAuthStatus() 메서드 포팅 */ static async checkAuthStatus(req: Request, res: Response): Promise { try { const authHeader = req.get("Authorization"); const token = authHeader && authHeader.split(" ")[1]; if (!token) { res.status(200).json({ success: true, message: "세션 상태 확인", data: { isLoggedIn: false, isAdmin: false, }, }); return; } const validation = JwtUtils.validateToken(token); if (!validation.isValid) { res.status(200).json({ success: true, message: "세션 상태 확인", data: { isLoggedIn: false, isAdmin: false, error: validation.error, }, }); return; } // 토큰에서 사용자 정보 추출하여 관리자 권한 확인 let isAdmin = false; try { const userInfo = JwtUtils.verifyToken(token); // 기존 Java 로직과 동일: plm_admin 사용자만 관리자로 인식 isAdmin = userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN"; logger.info(`인증 상태 확인: ${userInfo.userId}, 관리자: ${isAdmin}`); } catch (error) { logger.error(`토큰에서 사용자 정보 추출 실패: ${error}`); } res.status(200).json({ success: true, message: "세션 상태 확인", data: { isLoggedIn: true, isAdmin: isAdmin, }, }); } catch (error) { logger.error( `세션 상태 확인 API 오류: ${error instanceof Error ? error.message : error}` ); res.status(500).json({ success: false, message: "세션 상태 확인 중 오류가 발생했습니다.", error: { code: "SESSION_CHECK_ERROR", details: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", }, }); } } /** * POST /api/auth/refresh * JWT 토큰 갱신 API */ static async refreshToken(req: Request, res: Response): Promise { try { const authHeader = req.get("Authorization"); const token = authHeader && authHeader.split(" ")[1]; if (!token) { res.status(401).json({ success: false, message: "토큰이 필요합니다.", error: { code: "TOKEN_MISSING", details: "인증 토큰이 필요합니다.", }, }); return; } const newToken = JwtUtils.refreshToken(token); res.status(200).json({ success: true, message: "토큰 갱신 성공", data: { token: newToken, }, }); } catch (error) { logger.error( `토큰 갱신 API 오류: ${error instanceof Error ? error.message : error}` ); res.status(401).json({ success: false, message: "토큰 갱신에 실패했습니다.", error: { code: "TOKEN_REFRESH_ERROR", details: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", }, }); } } /** * POST /api/auth/signup * 공차중계 회원가입 API */ static async signup(req: Request, res: Response): Promise { try { const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body; logger.info(`=== 공차중계 회원가입 API 호출 ===`); logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}`); // 입력값 검증 if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) { res.status(400).json({ success: false, message: "필수 입력값이 누락되었습니다.", error: { code: "INVALID_INPUT", details: "아이디, 비밀번호, 이름, 연락처, 면허번호, 차량번호는 필수입니다.", }, }); return; } // 회원가입 처리 const signupResult = await AuthService.signupDriver({ userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType, }); if (signupResult.success) { logger.info(`공차중계 회원가입 성공: ${userId}`); res.status(201).json({ success: true, message: "회원가입이 완료되었습니다.", }); } else { logger.warn(`공차중계 회원가입 실패: ${userId} - ${signupResult.message}`); res.status(400).json({ success: false, message: signupResult.message || "회원가입에 실패했습니다.", error: { code: "SIGNUP_FAILED", details: signupResult.message, }, }); } } catch (error) { logger.error("공차중계 회원가입 API 오류:", error); res.status(500).json({ success: false, message: "회원가입 처리 중 오류가 발생했습니다.", error: { code: "SIGNUP_ERROR", details: error instanceof Error ? error.message : "알 수 없는 오류", }, }); } } /** * POP 레이아웃 자동 초기화 * 해당 회사의 screen_layouts_pop 레코드가 없으면 * 템플릿(공통 '*' 또는 COMPANY_7)에서 복제하여 생성 * * 기본 POP 화면 ID: 5, 6, 7, 8, 6526, 6527, 6528, 6529 */ static async initPopLayoutsForCompany(companyCode: string): Promise { // SUPER_ADMIN이나 공통(*)은 초기화 불필요 if (companyCode === "*" || companyCode === "COMPANY_7") return; const POP_SCREEN_IDS = [5, 6, 7, 8, 6526, 6527, 6528, 6529]; // 이미 해당 회사의 POP 레이아웃이 하나라도 있으면 스킵 (중복 초기화 방지) const existing = await query<{ cnt: string }>( `SELECT COUNT(*)::text AS cnt FROM screen_layouts_pop WHERE company_code = $1 AND screen_id = ANY($2::int[])`, [companyCode, POP_SCREEN_IDS], ); const existingCount = parseInt(existing[0]?.cnt || "0", 10); if (existingCount > 0) { logger.debug(`POP 레이아웃 이미 존재 (${companyCode}): ${existingCount}개, 스킵`); return; } logger.info(`POP 레이아웃 자동 초기화 시작: ${companyCode}`); // 회사명 조회 (레이아웃 내 회사명 치환용) const companyInfo = await queryOne<{ company_name: string }>( `SELECT company_name FROM company_mng WHERE company_code = $1`, [companyCode], ); const companyName = companyInfo?.company_name || companyCode; let initCount = 0; for (const screenId of POP_SCREEN_IDS) { // 템플릿 조회: 공통(*) 우선, 없으면 COMPANY_7 폴백 let template = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = '*'`, [screenId], ); if (!template) { template = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = 'COMPANY_7'`, [screenId], ); } if (!template) { logger.debug(`POP 템플릿 없음 (screen_id=${screenId}), 스킵`); continue; } // 레이아웃 복제 + 회사명 치환 const layoutStr = JSON.stringify(template.layout_data); const replacedStr = layoutStr .replace(/\(주\)탑씰/g, companyName) .replace(/탑씰/g, companyName) .replace(/TOPSEAL/gi, companyName); await query( `INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by) VALUES ($1, $2, $3, NOW(), NOW(), 'SYSTEM', 'SYSTEM') ON CONFLICT (screen_id, company_code) DO NOTHING`, [screenId, companyCode, replacedStr], ); initCount++; } logger.info(`POP 레이아웃 자동 초기화 완료: ${companyCode}, ${initCount}개 화면`); } }