From ce130ee225ac6b7dbc8994688c0ba7a5e7ade73d Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 25 Aug 2025 13:12:17 +0900 Subject: [PATCH] =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 905 ++++++++++++++++-- backend-node/src/routes/adminRoutes.ts | 10 + backend-node/src/services/authService.ts | 8 +- backend-node/src/utils/encryptUtil.ts | 145 +++ docs/NodeJS_Refactoring_Rules.md | 189 ++++ frontend/components/admin/UserFormModal.tsx | 79 +- frontend/components/admin/UserTable.tsx | 20 +- frontend/lib/api/user.ts | 4 +- frontend/types/user.ts | 66 +- 9 files changed, 1256 insertions(+), 170 deletions(-) create mode 100644 backend-node/src/utils/encryptUtil.ts diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 2f3fd6d0..3bf5e82e 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -4,6 +4,7 @@ import { AuthenticatedRequest } from "../types/auth"; import { ApiResponse } from "../types/common"; import { Client } from "pg"; import { AdminService } from "../services/adminService"; +import { EncryptUtil } from "../utils/encryptUtil"; /** * 관리자 메뉴 목록 조회 @@ -172,67 +173,215 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { user: req.user, }); - // 임시 더미 데이터 반환 (실제로는 데이터베이스에서 조회) - const { page = 1, countPerPage = 20 } = req.query; + const { page = 1, countPerPage = 20, search, deptCode, status } = req.query; - const dummyUsers = [ - { - userId: "plm_admin", - userName: "관리자", - deptName: "IT팀", - companyCode: "ILSHIN", - userType: "admin", - email: "admin@ilshin.com", - status: "active", - regDate: "2024-01-15", - }, - { - userId: "user001", - userName: "홍길동", - deptName: "영업팀", - companyCode: "ILSHIN", - userType: "user", - email: "hong@ilshin.com", - status: "active", - regDate: "2024-01-16", - }, - { - userId: "user002", - userName: "김철수", - deptName: "개발팀", - companyCode: "ILSHIN", - userType: "user", - email: "kim@ilshin.com", - status: "inactive", - regDate: "2024-01-17", - }, - ]; - - // 페이징 처리 - const startIndex = (Number(page) - 1) * Number(countPerPage); - const endIndex = startIndex + Number(countPerPage); - const paginatedUsers = dummyUsers.slice(startIndex, endIndex); - - const response = { - success: true, - data: { - users: paginatedUsers, - pagination: { - currentPage: Number(page), - countPerPage: Number(countPerPage), - totalCount: dummyUsers.length, - totalPages: Math.ceil(dummyUsers.length / Number(countPerPage)), - }, - }, - message: "사용자 목록 조회 성공", - }; - - logger.info("사용자 목록 조회 성공", { - totalCount: dummyUsers.length, - returnedCount: paginatedUsers.length, + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", }); - res.status(200).json(response); + await client.connect(); + + try { + // 기본 사용자 목록 조회 쿼리 + let query = ` + SELECT + u.sabun, + u.user_id, + u.user_name, + u.user_name_eng, + u.dept_code, + u.dept_name, + u.position_code, + u.position_name, + u.email, + u.tel, + u.cell_phone, + u.user_type, + u.user_type_name, + u.regdate, + u.status, + u.company_code, + u.locale, + d.dept_name as dept_name_full + FROM user_info u + LEFT JOIN dept_info d ON u.dept_code = d.dept_code + WHERE 1=1 + `; + + const queryParams: any[] = []; + let paramIndex = 1; + + // 검색 조건 추가 + if (search) { + query += ` AND ( + u.user_name ILIKE $${paramIndex} OR + u.user_id ILIKE $${paramIndex} OR + u.sabun ILIKE $${paramIndex} OR + u.email ILIKE $${paramIndex} + )`; + queryParams.push(`%${search}%`); + paramIndex++; + } + + // 부서 코드 필터 + if (deptCode) { + query += ` AND u.dept_code = $${paramIndex}`; + queryParams.push(deptCode); + paramIndex++; + } + + // 상태 필터 + if (status) { + query += ` AND u.status = $${paramIndex}`; + queryParams.push(status); + paramIndex++; + } + + // 정렬 + query += ` ORDER BY u.regdate DESC, u.user_name`; + + // 총 개수 조회 + const countQuery = ` + SELECT COUNT(*) as total + FROM user_info u + WHERE 1=1 + ${ + search + ? `AND ( + u.user_name ILIKE $1 OR + u.user_id ILIKE $1 OR + u.sabun ILIKE $1 OR + u.email ILIKE $1 + )` + : "" + } + ${deptCode ? `AND u.dept_code = $${search ? 2 : 1}` : ""} + ${status ? `AND u.status = $${search ? (deptCode ? 3 : 2) : deptCode ? 2 : 1}` : ""} + `; + + const countParams = queryParams.slice(0, -2); // 페이징 파라미터 제외 + const countResult = await client.query(countQuery, countParams); + const totalCount = parseInt(countResult.rows[0].total); + + // 페이징 적용 + const offset = (Number(page) - 1) * Number(countPerPage); + query += ` LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`; + queryParams.push(Number(countPerPage), offset); + + // 사용자 목록 조회 + const result = await client.query(query, queryParams); + const users = result.rows; + + // 디버깅: 원본 데이터베이스 조회 결과 로깅 + logger.info("=== 원본 데이터베이스 조회 결과 ==="); + logger.info("총 조회된 사용자 수:", users.length); + if (users.length > 0) { + logger.info("첫 번째 사용자 원본 데이터:", { + user_id: users[0].user_id, + user_name: users[0].user_name, + user_name_eng: users[0].user_name_eng, + dept_code: users[0].dept_code, + dept_name: users[0].dept_name, + position_code: users[0].position_code, + position_name: users[0].position_name, + email: users[0].email, + tel: users[0].tel, + cell_phone: users[0].cell_phone, + user_type: users[0].user_type, + user_type_name: users[0].user_type_name, + status: users[0].status, + company_code: users[0].company_code, + locale: users[0].locale, + regdate: users[0].regdate, + }); + } + + // 응답 데이터 가공 + const processedUsers = users.map((user) => { + // 디버깅: 각 필드별 값 확인 + const processedUser = { + userId: user.user_id, + userName: user.user_name, + userNameEng: user.user_name_eng || null, + sabun: user.sabun || null, + deptCode: user.dept_code || null, + deptName: user.dept_name || user.dept_name_full || null, + positionCode: user.position_code || null, + positionName: user.position_name || null, + email: user.email || null, + tel: user.tel || null, + cellPhone: user.cell_phone || null, + userType: user.user_type || null, + userTypeName: user.user_type_name || null, + status: user.status || "active", + companyCode: user.company_code || null, + locale: user.locale || null, + regDate: user.regdate + ? user.regdate.toISOString().split("T")[0] + : null, + }; + + // 빈 문자열을 null로 변환 + Object.keys(processedUser).forEach((key) => { + if (processedUser[key as keyof typeof processedUser] === "") { + (processedUser as any)[key] = null; + } + }); + + // 디버깅: 가공된 데이터 로깅 + logger.info(`사용자 ${user.user_id} 가공 결과:`, { + 원본: { + user_id: user.user_id, + user_name: user.user_name, + user_name_eng: user.user_name_eng, + dept_code: user.dept_code, + dept_name: user.dept_name, + position_code: user.position_code, + position_name: user.position_name, + email: user.email, + tel: user.tel, + cell_phone: user.cell_phone, + user_type: user.user_type, + user_type_name: user.user_type_name, + status: user.status, + company_code: user.company_code, + locale: user.locale, + regdate: user.regdate, + }, + 가공후: processedUser, + }); + + return processedUser; + }); + + const response = { + success: true, + data: { + users: processedUsers, + pagination: { + currentPage: Number(page), + countPerPage: Number(countPerPage), + totalCount: totalCount, + totalPages: Math.ceil(totalCount / Number(countPerPage)), + }, + }, + message: "사용자 목록 조회 성공", + }; + + logger.info("사용자 목록 조회 성공", { + totalCount, + returnedCount: processedUsers.length, + currentPage: Number(page), + countPerPage: Number(countPerPage), + }); + + res.status(200).json(response); + } finally { + await client.end(); + } } catch (error) { logger.error("사용자 목록 조회 실패", { error }); res.status(500).json({ @@ -386,7 +535,9 @@ export const setUserLocale = async ( }; /** + * GET /api/admin/companies * 회사 목록 조회 API + * 기존 Java AdminController의 회사 목록 조회 기능 포팅 */ export const getCompanyList = async ( req: AuthenticatedRequest, @@ -398,44 +549,61 @@ export const getCompanyList = async ( user: req.user, }); - // 임시 더미 데이터 반환 (실제로는 데이터베이스에서 조회) - const dummyCompanies = [ - { - company_code: "ILSHIN", - company_name: "일신제강", - status: "active", - writer: "admin", - regdate: new Date().toISOString(), - }, - { - company_code: "HUTECH", - company_name: "후테크", - status: "active", - writer: "admin", - regdate: new Date().toISOString(), - }, - { - company_code: "DAIN", - company_name: "다인", - status: "active", - writer: "admin", - regdate: new Date().toISOString(), - }, - ]; - - // 프론트엔드에서 기대하는 응답 형식으로 변환 - const response = { - success: true, - data: dummyCompanies, - message: "회사 목록 조회 성공", - }; - - logger.info("회사 목록 조회 성공", { - totalCount: dummyCompanies.length, - response: response, + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", }); - res.status(200).json(response); + await client.connect(); + + try { + // 회사 목록 조회 쿼리 + const query = ` + SELECT + company_code, + company_name, + status, + writer, + regdate, + 'company' as data_type + FROM company_mng + WHERE status = 'active' OR status IS NULL + ORDER BY company_name + `; + + const result = await client.query(query); + const companies = result.rows; + + // 프론트엔드에서 기대하는 응답 형식으로 변환 + const response = { + success: true, + data: companies.map((company) => ({ + company_code: company.company_code, + company_name: company.company_name, + status: company.status || "active", + writer: company.writer, + regdate: company.regdate + ? company.regdate.toISOString() + : new Date().toISOString(), + data_type: company.data_type, + })), + message: "회사 목록 조회 성공", + }; + + logger.info("회사 목록 조회 성공", { + totalCount: companies.length, + companies: companies.map((c) => ({ + code: c.company_code, + name: c.company_name, + })), + }); + + res.status(200).json(response); + } finally { + await client.end(); + } } catch (error) { logger.error("회사 목록 조회 실패", { error }); res.status(500).json({ @@ -1259,3 +1427,552 @@ export async function getCompanyListFromDB( }); } } + +/** + * GET /api/admin/departments + * 부서 목록 조회 API + * 기존 Java AdminController의 부서 목록 조회 기능 포팅 + */ +export const getDepartmentList = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + logger.info("부서 목록 조회 요청", { + query: req.query, + user: req.user, + }); + + const { companyCode, status, search } = req.query; + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + // 부서 목록 조회 쿼리 + let query = ` + SELECT + dept_code, + parent_dept_code, + dept_name, + master_sabun, + master_user_id, + location, + location_name, + regdate, + data_type, + status, + sales_yn, + company_name + FROM dept_info + WHERE 1=1 + `; + + const queryParams: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 + if (companyCode) { + query += ` AND company_name = $${paramIndex}`; + queryParams.push(companyCode); + paramIndex++; + } + + // 상태 필터 + if (status) { + query += ` AND status = $${paramIndex}`; + queryParams.push(status); + paramIndex++; + } + + // 검색 조건 + if (search) { + query += ` AND ( + dept_name ILIKE $${paramIndex} OR + dept_code ILIKE $${paramIndex} OR + location_name ILIKE $${paramIndex} + )`; + queryParams.push(`%${search}%`); + paramIndex++; + } + + // 정렬 (상위 부서 먼저, 그 다음 부서명 순) + query += ` ORDER BY + CASE WHEN parent_dept_code IS NULL OR parent_dept_code = '' THEN 0 ELSE 1 END, + dept_name`; + + const result = await client.query(query, queryParams); + const departments = result.rows; + + // 부서 트리 구조 생성 + const deptMap = new Map(); + const rootDepartments: any[] = []; + + // 모든 부서를 맵에 저장 + departments.forEach((dept) => { + deptMap.set(dept.dept_code, { + deptCode: dept.dept_code, + deptName: dept.dept_name, + parentDeptCode: dept.parent_dept_code, + masterSabun: dept.master_sabun, + masterUserId: dept.master_user_id, + location: dept.location, + locationName: dept.location_name, + regdate: dept.regdate ? dept.regdate.toISOString() : null, + dataType: dept.data_type, + status: dept.status || "active", + salesYn: dept.sales_yn, + companyName: dept.company_name, + children: [], + }); + }); + + // 부서 트리 구조 생성 + departments.forEach((dept) => { + const deptNode = deptMap.get(dept.dept_code); + + if (dept.parent_dept_code && deptMap.has(dept.parent_dept_code)) { + // 상위 부서가 있으면 children에 추가 + const parentDept = deptMap.get(dept.parent_dept_code); + parentDept.children.push(deptNode); + } else { + // 상위 부서가 없으면 루트 부서로 추가 + rootDepartments.push(deptNode); + } + }); + + const response = { + success: true, + data: { + departments: rootDepartments, + flatList: departments.map((dept) => ({ + deptCode: dept.dept_code, + deptName: dept.dept_name, + parentDeptCode: dept.parent_dept_code, + masterSabun: dept.master_sabun, + masterUserId: dept.master_user_id, + location: dept.location, + locationName: dept.location_name, + regdate: dept.regdate ? dept.regdate.toISOString() : null, + dataType: dept.data_type, + status: dept.status || "active", + salesYn: dept.sales_yn, + companyName: dept.company_name, + })), + }, + message: "부서 목록 조회 성공", + total: departments.length, + }; + + logger.info("부서 목록 조회 성공", { + totalCount: departments.length, + rootCount: rootDepartments.length, + }); + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("부서 목록 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "부서 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * GET /api/admin/users/:userId + * 사용자 상세 조회 API + * 기존 Java AdminController의 사용자 상세 조회 기능 포팅 + */ +export const getUserInfo = async (req: AuthenticatedRequest, res: Response) => { + try { + const { userId } = req.params; + logger.info(`사용자 상세 조회 요청 - userId: ${userId}`, { + user: req.user, + }); + + if (!userId) { + res.status(400).json({ + success: false, + message: "사용자 ID가 필요합니다.", + error: { + code: "USER_ID_REQUIRED", + details: "userId parameter is required", + }, + }); + return; + } + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + // 사용자 상세 정보 조회 쿼리 + const query = ` + SELECT + u.sabun, + u.user_id, + u.user_name, + u.user_name_eng, + u.user_name_cn, + u.dept_code, + u.dept_name, + u.position_code, + u.position_name, + u.email, + u.tel, + u.cell_phone, + u.user_type, + u.user_type_name, + u.regdate, + u.status, + u.end_date, + u.fax_no, + u.partner_objid, + u.rank, + u.locale, + u.company_code, + u.data_type, + d.dept_name as dept_name_full, + d.parent_dept_code, + d.location, + d.location_name, + d.sales_yn, + d.company_name as dept_company_name + FROM user_info u + LEFT JOIN dept_info d ON u.dept_code = d.dept_code + WHERE u.user_id = $1 + `; + + const result = await client.query(query, [userId]); + + if (result.rows.length === 0) { + res.status(404).json({ + success: false, + message: "사용자를 찾을 수 없습니다.", + error: { + code: "USER_NOT_FOUND", + details: `User ID: ${userId}`, + }, + }); + return; + } + + const user = result.rows[0]; + + // 응답 데이터 가공 + const userInfo = { + sabun: user.sabun, + userId: user.user_id, + userName: user.user_name, + userNameEng: user.user_name_eng, + userNameCn: user.user_name_cn, + deptCode: user.dept_code, + deptName: user.dept_name || user.dept_name_full, + positionCode: user.position_code, + positionName: user.position_name, + email: user.email, + tel: user.tel, + cellPhone: user.cell_phone, + userType: user.user_type, + userTypeName: user.user_type_name, + regdate: user.regdate ? user.regdate.toISOString() : null, + status: user.status || "active", + endDate: user.end_date ? user.end_date.toISOString() : null, + faxNo: user.fax_no, + partnerObjid: user.partner_objid, + rank: user.rank, + locale: user.locale, + companyCode: user.company_code, + dataType: user.data_type, + // 부서 정보 + deptInfo: { + deptCode: user.dept_code, + deptName: user.dept_name || user.dept_name_full, + parentDeptCode: user.parent_dept_code, + location: user.location, + locationName: user.location_name, + salesYn: user.sales_yn, + companyName: user.dept_company_name, + }, + }; + + const response = { + success: true, + data: userInfo, + message: "사용자 상세 정보 조회 성공", + }; + + logger.info("사용자 상세 정보 조회 성공", { + userId, + userName: user.user_name, + }); + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("사용자 상세 정보 조회 실패", { + error, + userId: req.params.userId, + }); + res.status(500).json({ + success: false, + message: "사용자 상세 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * POST /api/admin/users/check-duplicate + * 사용자 ID 중복 체크 API + * 기존 Java AdminController의 checkDuplicateUserId 기능 포팅 + */ +export const checkDuplicateUserId = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { userId } = req.body; + logger.info(`사용자 ID 중복 체크 요청 - userId: ${userId}`, { + user: req.user, + }); + + if (!userId) { + res.status(400).json({ + success: false, + message: "사용자 ID가 필요합니다.", + error: { + code: "USER_ID_REQUIRED", + details: "userId is required", + }, + }); + return; + } + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + // 사용자 ID 중복 체크 쿼리 + const query = ` + SELECT COUNT(*) as count + FROM user_info + WHERE user_id = $1 + `; + + const result = await client.query(query, [userId]); + const count = parseInt(result.rows[0].count); + + const isDuplicate = count > 0; + + const response = { + success: true, + data: { + isDuplicate, + count, + message: isDuplicate + ? "이미 사용 중인 사용자 ID입니다." + : "사용 가능한 사용자 ID입니다.", + }, + message: "사용자 ID 중복 체크 완료", + }; + + logger.info("사용자 ID 중복 체크 완료", { + userId, + isDuplicate, + count, + }); + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("사용자 ID 중복 체크 실패", { + error, + userId: req.body?.userId, + }); + res.status(500).json({ + success: false, + message: "사용자 ID 중복 체크 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * POST /api/admin/users + * 사용자 등록/수정 API + * 기존 Java AdminController의 saveUserInfo 기능 포팅 + */ +export const saveUser = async (req: AuthenticatedRequest, res: Response) => { + try { + const userData = req.body; + logger.info("사용자 저장 요청", { userData, user: req.user }); + + // 필수 필드 검증 + const requiredFields = ["userId", "userName", "userPassword"]; + for (const field of requiredFields) { + if (!userData[field] || userData[field].trim() === "") { + res.status(400).json({ + success: false, + message: `${field}는 필수 입력 항목입니다.`, + error: { + code: "REQUIRED_FIELD_MISSING", + details: `Required field: ${field}`, + }, + }); + return; + } + } + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + // 기존 사용자 확인 + const checkQuery = ` + SELECT user_id FROM user_info WHERE user_id = $1 + `; + const checkResult = await client.query(checkQuery, [userData.userId]); + const isUpdate = checkResult.rows.length > 0; + + if (isUpdate) { + // 기존 사용자 수정 + const updateQuery = ` + UPDATE user_info SET + user_name = $1, + user_name_eng = $2, + user_password = $3, + dept_code = $4, + dept_name = $5, + position_code = $6, + position_name = $7, + email = $8, + tel = $9, + cell_phone = $10, + user_type = $11, + user_type_name = $12, + sabun = $13, + company_code = $14, + status = $15, + locale = $16 + WHERE user_id = $17 + `; + + const updateValues = [ + userData.userName, + userData.userNameEng || null, + await EncryptUtil.encrypt(userData.userPassword), // 비밀번호 암호화 + userData.deptCode || null, + userData.deptName || null, + userData.positionCode || null, + userData.positionName || null, + userData.email || null, + userData.tel || null, + userData.cellPhone || null, + userData.userType || null, + userData.userTypeName || null, + userData.sabun || null, + userData.companyCode || null, + userData.status || "active", + userData.locale || null, + userData.userId, + ]; + + await client.query(updateQuery, updateValues); + logger.info("사용자 정보 수정 완료", { userId: userData.userId }); + } else { + // 새 사용자 등록 + const insertQuery = ` + INSERT INTO user_info ( + user_id, user_name, user_name_eng, user_password, dept_code, dept_name, + position_code, position_name, email, tel, cell_phone, user_type, + user_type_name, sabun, company_code, status, locale, regdate + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18 + ) + `; + + const insertValues = [ + userData.userId, + userData.userName, + userData.userNameEng || null, + await EncryptUtil.encrypt(userData.userPassword), // 비밀번호 암호화 + userData.deptCode || null, + userData.deptName || null, + userData.positionCode || null, + userData.positionName || null, + userData.email || null, + userData.tel || null, + userData.cellPhone || null, + userData.userType || null, + userData.userTypeName || null, + userData.sabun || null, + userData.companyCode || null, + userData.status || "active", + userData.locale || null, + new Date(), + ]; + + await client.query(insertQuery, insertValues); + logger.info("새 사용자 등록 완료", { userId: userData.userId }); + } + + const response = { + success: true, + result: true, + message: isUpdate + ? "사용자 정보가 수정되었습니다." + : "사용자가 등록되었습니다.", + data: { + userId: userData.userId, + isUpdate, + }, + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("사용자 저장 실패", { error, userData: req.body }); + res.status(500).json({ + success: false, + result: false, + message: "사용자 저장 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index b7ac1a3e..9e11161f 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -8,6 +8,10 @@ import { deleteMenu, // 메뉴 삭제 deleteMenusBatch, // 메뉴 일괄 삭제 getUserList, + getUserInfo, // 사용자 상세 조회 + getDepartmentList, // 부서 목록 조회 + checkDuplicateUserId, // 사용자 ID 중복 체크 + saveUser, // 사용자 등록/수정 getCompanyList, getCompanyListFromDB, // 실제 DB에서 회사 목록 조회 getUserLocale, @@ -42,6 +46,12 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제 // 사용자 관리 API router.get("/users", getUserList); +router.get("/users/:userId", getUserInfo); // 사용자 상세 조회 +router.post("/users", saveUser); // 사용자 등록/수정 +router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 + +// 부서 관리 API +router.get("/departments", getDepartmentList); // 부서 목록 조회 // 회사 관리 API router.get("/companies", getCompanyList); diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index f1f14274..5aafc132 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -1,12 +1,14 @@ // 인증 서비스 // 기존 Java LoginService를 Node.js로 포팅 -import prisma from "../config/database"; -import { PasswordUtils } from "../utils/passwordUtils"; +import { PrismaClient } from "@prisma/client"; import { JwtUtils } from "../utils/jwtUtils"; +import { EncryptUtil } from "../utils/encryptUtil"; import { PersonBean, LoginResult, LoginLogData } from "../types/auth"; import { logger } from "../utils/logger"; +const prisma = new PrismaClient(); + export class AuthService { /** * 기존 Java LoginService.loginPwdCheck() 메서드 포팅 @@ -42,7 +44,7 @@ export class AuthService { } // 비밀번호 검증 (기존 EncryptUtil 로직 사용) - if (PasswordUtils.matches(password, dbPassword)) { + if (EncryptUtil.matches(password, dbPassword)) { logger.info(`비밀번호 일치로 로그인 성공: ${userId}`); return { loginResult: true, diff --git a/backend-node/src/utils/encryptUtil.ts b/backend-node/src/utils/encryptUtil.ts new file mode 100644 index 00000000..410f2379 --- /dev/null +++ b/backend-node/src/utils/encryptUtil.ts @@ -0,0 +1,145 @@ +import crypto from "crypto"; + +/** + * 기존 Java EncryptUtil과 동일한 AES 암호화 로직 + * AES/ECB/NoPadding 방식 사용 + */ +export class EncryptUtil { + private static readonly keyName = "ILJIAESSECRETKEY"; // 16자리 키 + private static readonly algorithm = "AES"; + + /** + * 문자열을 AES로 암호화 + * @param source 원본 문자열 + * @returns 암호화된 16진수 문자열 + */ + public static encrypt(source: string): string { + try { + const key = Buffer.from(this.keyName, "utf8"); + const paddedSource = this.addPadding(Buffer.from(source, "utf8")); + + const cipher = crypto.createCipher("aes-128-ecb", key); + cipher.setAutoPadding(false); // NoPadding 모드 + + const encrypted = Buffer.concat([ + cipher.update(paddedSource), + cipher.final(), + ]); + + return this.fromHex(encrypted); + } catch (error) { + console.error("암호화 실패:", error); + throw new Error("암호화 중 오류가 발생했습니다."); + } + } + + /** + * 암호화된 문자열을 복호화 + * @param source 암호화된 16진수 문자열 + * @returns 복호화된 원본 문자열 + */ + public static decrypt(source: string): string { + try { + const key = Buffer.from(this.keyName, "utf8"); + const encryptedBytes = this.toBytes(source); + + const decipher = crypto.createDecipher("aes-128-ecb", key); + decipher.setAutoPadding(false); // NoPadding 모드 + + const decrypted = Buffer.concat([ + decipher.update(encryptedBytes), + decipher.final(), + ]); + + const unpadded = this.removePadding(decrypted); + return unpadded.toString("utf8"); + } catch (error) { + console.error("복호화 실패:", error); + throw new Error("복호화 중 오류가 발생했습니다."); + } + } + + /** + * SHA-256 해시 생성 + * @param source 원본 문자열 + * @returns SHA-256 해시 문자열 + */ + public static encryptSha256(source: string): string { + try { + const hash = crypto.createHash("sha256"); + hash.update(source); + return hash.digest("hex"); + } catch (error) { + console.error("SHA-256 해시 생성 실패:", error); + throw new Error("해시 생성 중 오류가 발생했습니다."); + } + } + + /** + * 패스워드 검증 (암호화된 패스워드와 평문 패스워드 비교) + * @param plainPassword 평문 패스워드 + * @param encryptedPassword 암호화된 패스워드 + * @returns 일치 여부 + */ + public static matches( + plainPassword: string, + encryptedPassword: string + ): boolean { + try { + const encrypted = this.encrypt(plainPassword); + return encrypted === encryptedPassword; + } catch (error) { + console.error("패스워드 검증 실패:", error); + return false; + } + } + + /** + * 바이트 배열을 16진수 문자열로 변환 + */ + private static fromHex(bytes: Buffer): string { + return bytes.toString("hex"); + } + + /** + * 16진수 문자열을 바이트 배열로 변환 + */ + private static toBytes(source: string): Buffer { + const buffer = Buffer.alloc(source.length / 2); + for (let i = 0; i < source.length; i += 2) { + buffer[i / 2] = parseInt(source.substr(i, 2), 16); + } + return buffer; + } + + /** + * 패딩 추가 (16바이트 블록 크기에 맞춤) + */ + private static addPadding(bytes: Buffer): Buffer { + const blockSize = 16; + const paddingSize = blockSize - (bytes.length % blockSize); + const padded = Buffer.alloc(bytes.length + paddingSize); + + bytes.copy(padded); + // 나머지 부분을 0x00으로 채움 + for (let i = bytes.length; i < padded.length; i++) { + padded[i] = 0x00; + } + + return padded; + } + + /** + * 패딩 제거 + */ + private static removePadding(bytes: Buffer): Buffer { + let endIndex = bytes.length - 1; + + // 끝에서부터 0x00이 아닌 첫 번째 바이트를 찾음 + while (endIndex >= 0 && bytes[endIndex] === 0x00) { + endIndex--; + } + + return bytes.slice(0, endIndex + 1); + } +} diff --git a/docs/NodeJS_Refactoring_Rules.md b/docs/NodeJS_Refactoring_Rules.md index e1daef34..629b1404 100644 --- a/docs/NodeJS_Refactoring_Rules.md +++ b/docs/NodeJS_Refactoring_Rules.md @@ -763,6 +763,195 @@ export const logger = winston.createLogger({ - [ ] 사용자 생성/수정 API - [ ] 사용자 비밀번호 변경 API +#### **Phase 2-2A: 사용자 관리 기능 Node.js 리팩토링 계획 (1주)** + +**📋 사용자 관리 기능 Node.js 리팩토링 개요** + +**목표**: 기존 Java Spring Boot의 사용자 관리 기능을 Node.js + TypeScript로 완전 리팩토링 + +**기존 Java 백엔드 (`@backend/`) 분석** + +- Spring Framework 기반의 `AdminController`와 `AdminService` +- MyBatis를 사용한 데이터베이스 접근 +- 사용자 CRUD, 권한 관리, 상태 변경 등 완전한 기능 구현 + +**현재 Node.js 백엔드 (`@backend-node/`) 상황** + +- 기본적인 사용자 목록 조회만 더미 데이터로 구현 +- 실제 데이터베이스 연동 부족 +- 사용자 관리 핵심 기능 미구현 + +**🎯 리팩토링 목표** + +1. **기존 Java 백엔드의 사용자 관리 기능을 Node.js로 완전 이전** +2. **Prisma ORM을 활용한 데이터베이스 연동** +3. **기존 API 응답 형식과 호환성 유지** +4. **보안 및 인증 기능 강화** + +**🛠️ 단계별 구현 계획** + +**Phase 2-2A-1: 데이터베이스 스키마 및 모델 정리 (1일)** + +- [x] Prisma 스키마에 `user_info` 테이블 정의 완료 +- [ ] 사용자 관련 추가 테이블 스키마 확인 (부서, 권한 등) +- [ ] 데이터 타입 및 관계 정의 + +**Phase 2-2A-2: 핵심 사용자 관리 API 구현 (3일)** + +**사용자 CRUD API** + +```typescript +// 기존 Java AdminController의 핵심 메서드들 +- GET /api/admin/users - 사용자 목록 조회 (페이징, 검색) +- GET /api/admin/users/:userId - 사용자 상세 조회 +- POST /api/admin/users - 사용자 등록/수정 +- PUT /api/admin/users/:userId/status - 사용자 상태 변경 +- DELETE /api/admin/users/:userId - 사용자 삭제 +``` + +**사용자 관리 부가 기능** + +```typescript +- POST /api/admin/users/check-duplicate - 사용자 ID 중복 체크 +- POST /api/admin/users/reset-password - 비밀번호 초기화 +- GET /api/admin/users/:userId/history - 사용자 변경 이력 +- GET /api/admin/departments - 부서 목록 조회 +``` + +**Phase 2-2A-3: 서비스 레이어 구현 (2일)** + +**AdminService 확장** + +```typescript +// 기존 Java AdminService의 핵심 메서드들 +- getEtcUserList() - 사용자 목록 조회 +- getEtcUserInfo() - 사용자 상세 정보 +- saveEtcUserInfo() - 사용자 저장/수정 +- checkDuplicateEtcUserId() - 중복 체크 +- changeUserStatus() - 상태 변경 +- getUserHistoryList() - 변경 이력 +``` + +**데이터베이스 연동** + +```typescript +- Prisma ORM을 사용한 PostgreSQL 연동 +- 트랜잭션 처리 +- 에러 핸들링 및 로깅 +``` + +**Phase 2-2A-4: 보안 및 검증 강화 (1일)** + +**입력값 검증** + +```typescript +- 사용자 입력 데이터 검증 +- SQL 인젝션 방지 +- XSS 방지 +``` + +**권한 관리** + +```typescript +- 사용자별 권한 체크 +- 메뉴 접근 권한 검증 +- 역할 기반 접근 제어 (RBAC) +``` + +**🔄 구현 우선순위** + +**High Priority (1-2일차)** + +1. 사용자 목록 조회 API (실제 DB 연동) +2. 사용자 상세 조회 API +3. 사용자 등록/수정 API +4. 기본적인 에러 핸들링 + +**Medium Priority (3-4일차)** + +1. 사용자 상태 변경 API +2. 사용자 ID 중복 체크 API +3. 부서 목록 조회 API +4. 페이징 및 검색 기능 + +**Low Priority (5일차)** + +1. 사용자 변경 이력 API +2. 비밀번호 초기화 API +3. 사용자 삭제 API +4. 고급 검색 및 필터링 + +**🔧 기술적 고려사항** + +**데이터베이스 연동** + +- Prisma ORM 사용으로 타입 안전성 확보 +- 기존 PostgreSQL 스키마와 호환성 유지 +- 마이그레이션 스크립트 작성 + +**API 호환성** + +- 기존 Java 백엔드와 동일한 응답 형식 유지 +- 프론트엔드 변경 최소화 +- 점진적 마이그레이션 지원 + +**성능 최적화** + +- 데이터베이스 인덱스 활용 +- 쿼리 최적화 +- 캐싱 전략 수립 + +**📊 테스트 계획** + +1. **단위 테스트**: 각 서비스 메서드별 테스트 +2. **통합 테스트**: API 엔드포인트별 테스트 +3. **데이터베이스 테스트**: 실제 DB 연동 테스트 +4. **성능 테스트**: 대용량 데이터 처리 테스트 + +**📝 기존 Java 코드 분석 결과** + +**AdminController 주요 메서드** + +```java +// 사용자 목록 조회 +@RequestMapping("/admin/userMngList.do") +public String userMngList(HttpServletRequest request, @RequestParam Map paramMap) + +// 사용자 정보 저장 +@RequestMapping("/admin/saveUserInfo.do") +public String saveUserInfo(HttpServletRequest request, @RequestParam Map paramMap) + +// 사용자 ID 중복 체크 +@RequestMapping("/admin/checkDuplicateUserId.do") +public String checkDuplicateUserId(HttpServletRequest request, @RequestParam Map paramMap) + +// 사용자 상태 변경 +@RequestMapping("/admin/changeUserStatus.do") +public String changeUserStatus(HttpServletRequest request, @RequestParam Map paramMap) +``` + +**AdminService 주요 메서드** + +```java +// 사용자 목록 조회 +public List> getEtcUserList(HttpServletRequest request, Map paramMap) + +// 사용자 정보 저장 +public Map saveEtcUserInfo(HttpServletRequest request, Map paramMap) + +// 사용자 ID 중복 체크 +public Map checkDuplicateEtcUserId(Map paramMap) + +// 사용자 상태 변경 +public Map changeUserStatus(Map paramMap) +``` + +**📋 다음 단계** + +이 계획에 따라 **Phase 2-2A**를 시작하여 단계적으로 사용자 관리 기능을 구현하겠습니다. + +**시작 지점**: 사용자 목록 조회 API부터 실제 데이터베이스 연동으로 구현 + #### **Phase 2-2A: 메뉴 관리 API (완료 ✅)** - [x] 관리자 메뉴 조회 API (`GET /api/admin/menus`) - **완료: 기존 `AdminController.getAdminMenuList()` 포팅** diff --git a/frontend/components/admin/UserFormModal.tsx b/frontend/components/admin/UserFormModal.tsx index b673ae9b..0e32a95c 100644 --- a/frontend/components/admin/UserFormModal.tsx +++ b/frontend/components/admin/UserFormModal.tsx @@ -62,10 +62,24 @@ interface CompanyOption { } interface DepartmentOption { - CODE: string; - NAME: string; - DEPT_CODE: string; - DEPT_NAME: string; + deptCode?: string; + deptName?: string; + parentDeptCode?: string; + masterSabun?: string; + masterUserId?: string; + location?: string; + locationName?: string; + regdate?: string; + dataType?: string; + status?: string; + salesYn?: string; + companyName?: string; + children?: DepartmentOption[]; + // 기존 호환성을 위한 필드들 + CODE?: string; + NAME?: string; + DEPT_CODE?: string; + DEPT_NAME?: string; [key: string]: any; // 기타 필드들 } @@ -202,20 +216,21 @@ export function UserFormModal({ isOpen, onClose, onSuccess }: UserFormModalProps try { const response = await userAPI.checkDuplicateId(formData.userId); if (response.success && response.data) { - // result는 boolean 타입: true = 사용가능, false = 중복됨 - const isAvailable = response.data.result; + // 백엔드 API 응답 구조: { isDuplicate: boolean, message: string } + const isDuplicate = response.data.isDuplicate; + const message = response.data.message; - if (isAvailable) { - // 중복체크 성공 시 상태 업데이트 + if (!isDuplicate) { + // 중복되지 않음 (사용 가능) setIsUserIdChecked(true); setLastCheckedUserId(formData.userId); - setDuplicateCheckMessage(response.data.msg || "사용 가능한 사용자 ID입니다."); + setDuplicateCheckMessage(message || "사용 가능한 사용자 ID입니다."); setDuplicateCheckType("success"); } else { - // 중복된 ID인 경우 상태 초기화 + // 중복됨 (사용 불가) setIsUserIdChecked(false); setLastCheckedUserId(""); - setDuplicateCheckMessage(response.data.msg || "이미 사용 중인 사용자 ID입니다."); + setDuplicateCheckMessage(message || "이미 사용 중인 사용자 ID입니다."); setDuplicateCheckType("error"); } } @@ -280,15 +295,15 @@ export function UserFormModal({ isOpen, onClose, onSuccess }: UserFormModalProps try { const userDataToSend = { - user_id: formData.userId, - user_password: formData.userPassword, - user_name: formData.userName, + userId: formData.userId, + userPassword: formData.userPassword, + userName: formData.userName, email: formData.email || null, tel: formData.tel || null, - cell_phone: formData.cellPhone || null, - position_name: formData.positionName || null, - company_code: formData.companyCode, - dept_code: formData.deptCode || null, + cellPhone: formData.cellPhone || null, + positionName: formData.positionName || null, + companyCode: formData.companyCode, + deptCode: formData.deptCode || null, sabun: null, // 항상 null (테이블 1번 컬럼) status: "active", // 기본값 (테이블 18번 컬럼) }; @@ -460,14 +475,28 @@ export function UserFormModal({ isOpen, onClose, onSuccess }: UserFormModalProps - {departments.map((department) => ( - - {department.NAME || department.DEPT_NAME} + {Array.isArray(departments) && departments.length > 0 ? ( + departments + .filter((department) => { + const deptCode = department.deptCode || department.CODE || department.DEPT_CODE; + return deptCode && deptCode.trim() !== ""; + }) + .map((department) => { + const deptCode = department.deptCode || department.CODE || department.DEPT_CODE || ""; + const deptName = + department.deptName || department.NAME || department.DEPT_NAME || "Unknown Department"; + + return ( + + {deptName} + + ); + }) + ) : ( + + 부서 정보가 없습니다 - ))} + )} diff --git a/frontend/components/admin/UserTable.tsx b/frontend/components/admin/UserTable.tsx index 7eaa38fb..bc5714e9 100644 --- a/frontend/components/admin/UserTable.tsx +++ b/frontend/components/admin/UserTable.tsx @@ -180,23 +180,23 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on {users.map((user, index) => ( {getRowNumber(index)} - {user.sabun} - {user.company_name || "-"} - {user.dept_name} - {user.position_name || "-"} - {user.user_id} - {user.user_name} - {user.tel || user.cell_phone || "-"} + {user.sabun || "-"} + {user.companyCode || "-"} + {user.deptName || "-"} + {user.positionName || "-"} + {user.userId} + {user.userName} + {user.tel || user.cellPhone || "-"} {user.email || "-"} - {formatDate(user.regdate)} + {formatDate(user.regDate)}
handleStatusToggle(user, checked)} - aria-label={`${user.user_name} 상태 토글`} + aria-label={`${user.userName} 상태 토글`} /> onPasswordReset(user.user_id, user.user_name || user.user_id)} + onClick={() => onPasswordReset(user.userId, user.userName || user.userId)} className="h-8 w-8 p-0" title="비밀번호 초기화" > diff --git a/frontend/lib/api/user.ts b/frontend/lib/api/user.ts index 8d98b86f..d4f8efea 100644 --- a/frontend/lib/api/user.ts +++ b/frontend/lib/api/user.ts @@ -144,7 +144,9 @@ export async function getDepartmentList(companyCode?: string) { const response = await apiClient.get(`/admin/departments${params}`); if (response.data.success && response.data.data) { - return response.data.data; + // 백엔드 API 응답 구조: { data: { departments: [], flatList: [] } } + // departments 배열을 반환 (트리 구조) + return response.data.data.departments || []; } throw new Error(response.data.message || "부서 목록 조회에 실패했습니다."); diff --git a/frontend/types/user.ts b/frontend/types/user.ts index 39e5d60d..2361583c 100644 --- a/frontend/types/user.ts +++ b/frontend/types/user.ts @@ -2,43 +2,35 @@ * 사용자 관리 관련 타입 정의 */ -// 사용자 정보 인터페이스 (프론트엔드 친화적 camelCase) +// 사용자 정보 인터페이스 (백엔드 API 응답과 일치하는 camelCase) export interface User { - sabun: string; // 사번 - user_id: string; // 사용자 ID - user_name: string; // 사용자명 - user_name_eng?: string; // 영문명 - user_name_cn?: string; // 중문명 - company_name?: string; // 회사명 - dept_code: string; // 부서 코드 - dept_name: string; // 부서명 - position_code?: string; // 직책 코드 - position_name: string; // 직책 - email: string; // 이메일 - tel: string; // 전화번호 - cell_phone: string; // 휴대폰 - user_type?: string; // 사용자 유형 코드 - user_type_name: string; // 사용자 유형명 - regdate: string; // 등록일 (YYYY-MM-DD) - regdate_org?: string; // 원본 등록일 + sabun?: string; // 사번 + userId: string; // 사용자 ID + userName: string; // 사용자명 + userNameEng?: string; // 영문명 + userNameCn?: string; // 중문명 + companyCode?: string; // 회사 코드 + companyName?: string; // 회사명 + deptCode?: string; // 부서 코드 + deptName?: string; // 부서명 + positionCode?: string; // 직책 코드 + positionName?: string; // 직책 + email?: string; // 이메일 + tel?: string; // 전화번호 + cellPhone?: string; // 휴대폰 + userType?: string; // 사용자 유형 코드 + userTypeName?: string; // 사용자 유형명 + regDate?: string; // 등록일 (YYYY-MM-DD) status: string; // 상태 (active, inactive) - data_type?: string; // 데이터 타입 - enddate?: string; // 퇴사일 + dataType?: string; // 데이터 타입 + endDate?: string; // 퇴사일 + locale?: string; // 로케일 rnum?: number; // 행 번호 } // 사용자 검색 필터 export interface UserSearchFilter { - searchType?: - | "all" - | "sabun" - | "company_name" - | "dept_name" - | "position_name" - | "user_id" - | "user_name" - | "tel" - | "email"; // 검색 대상 + searchType?: "all" | "sabun" | "companyCode" | "deptName" | "positionName" | "userId" | "userName" | "tel" | "email"; // 검색 대상 searchValue?: string; // 검색어 } @@ -52,15 +44,15 @@ export interface UserTableColumn { // 사용자 등록/수정 폼 데이터 export interface UserFormData { - user_id: string; - user_name: string; - dept_code: string; - dept_name: string; - position_name: string; + userId: string; + userName: string; + deptCode: string; + deptName: string; + positionName: string; email: string; tel: string; - cell_phone: string; - user_type_name: string; + cellPhone: string; + userTypeName: string; status: string; }