사용자관리 등록

This commit is contained in:
kjs 2025-08-25 13:12:17 +09:00
parent 8667cb4780
commit ce130ee225
9 changed files with 1256 additions and 170 deletions

View File

@ -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",
});
}
};

View File

@ -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);

View File

@ -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,

View File

@ -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);
}
}

View File

@ -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<Map<String, Object>> getEtcUserList(HttpServletRequest request, Map<String, Object> paramMap)
// 사용자 정보 저장
public Map<String, Object> saveEtcUserInfo(HttpServletRequest request, Map<String, Object> paramMap)
// 사용자 ID 중복 체크
public Map<String, Object> checkDuplicateEtcUserId(Map<String, Object> paramMap)
// 사용자 상태 변경
public Map<String, Object> changeUserStatus(Map<String, Object> paramMap)
```
**📋 다음 단계**
이 계획에 따라 **Phase 2-2A**를 시작하여 단계적으로 사용자 관리 기능을 구현하겠습니다.
**시작 지점**: 사용자 목록 조회 API부터 실제 데이터베이스 연동으로 구현
#### **Phase 2-2A: 메뉴 관리 API (완료 ✅)**
- [x] 관리자 메뉴 조회 API (`GET /api/admin/menus`) - **완료: 기존 `AdminController.getAdminMenuList()` 포팅**

View File

@ -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
<SelectValue placeholder="부서 선택" />
</SelectTrigger>
<SelectContent>
{departments.map((department) => (
<SelectItem
key={department.CODE || department.DEPT_CODE}
value={department.CODE || department.DEPT_CODE}
>
{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 (
<SelectItem key={deptCode} value={deptCode}>
{deptName}
</SelectItem>
);
})
) : (
<SelectItem value="no-data" disabled>
</SelectItem>
))}
)}
</SelectContent>
</Select>
</div>

View File

@ -180,23 +180,23 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
{users.map((user, index) => (
<TableRow key={`${user.user_id}-${index}`} className="hover:bg-muted/50">
<TableCell className="font-mono text-sm font-medium">{getRowNumber(index)}</TableCell>
<TableCell className="font-mono text-sm">{user.sabun}</TableCell>
<TableCell className="font-medium">{user.company_name || "-"}</TableCell>
<TableCell className="font-medium">{user.dept_name}</TableCell>
<TableCell className="font-medium">{user.position_name || "-"}</TableCell>
<TableCell className="font-mono">{user.user_id}</TableCell>
<TableCell className="font-medium">{user.user_name}</TableCell>
<TableCell>{user.tel || user.cell_phone || "-"}</TableCell>
<TableCell className="font-mono text-sm">{user.sabun || "-"}</TableCell>
<TableCell className="font-medium">{user.companyCode || "-"}</TableCell>
<TableCell className="font-medium">{user.deptName || "-"}</TableCell>
<TableCell className="font-medium">{user.positionName || "-"}</TableCell>
<TableCell className="font-mono">{user.userId}</TableCell>
<TableCell className="font-medium">{user.userName}</TableCell>
<TableCell>{user.tel || user.cellPhone || "-"}</TableCell>
<TableCell className="max-w-[200px] truncate" title={user.email}>
{user.email || "-"}
</TableCell>
<TableCell>{formatDate(user.regdate)}</TableCell>
<TableCell>{formatDate(user.regDate)}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch
checked={user.status === "active"}
onCheckedChange={(checked) => handleStatusToggle(user, checked)}
aria-label={`${user.user_name} 상태 토글`}
aria-label={`${user.userName} 상태 토글`}
/>
<span
className={`text-sm font-medium ${user.status === "active" ? "text-blue-600" : "text-gray-500"}`}
@ -210,7 +210,7 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
<Button
variant="ghost"
size="sm"
onClick={() => 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="비밀번호 초기화"
>

View File

@ -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 || "부서 목록 조회에 실패했습니다.");

View File

@ -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;
}