'use server'; import * as cron from 'node-cron'; import db from '@/db/db'; import { employee as employeeTable } from '@/db/schema/knox/employee'; import { users, type UserDomainType } from '@/db/schema/users'; import { departmentDomainAssignments } from '@/db/schema/departmentDomainAssignments'; import { searchEmployees, Employee, } from '@/lib/knox-api/employee/employee'; import { sql, eq, and, ne } from 'drizzle-orm'; // 동기화 대상 회사 코드 (쉼표로 구분된 ENV) const COMPANIES = (process.env.KNOX_COMPANY_CODES || 'D60') .split(',') .map((c) => c.trim()) .filter(Boolean); const CRON_STRING = process.env.KNOX_EMPLOYEE_SYNC_CRON || '0 4 * * *'; const DO_FIRST_RUN = process.env.KNOX_EMPLOYEE_SYNC_FIRST_RUN === 'true'; // API 호출 제한 설정 (환경변수로 제어 가능) const DAILY_API_LIMIT = parseInt(process.env.KNOX_API_DAILY_LIMIT || '100'); const RATE_LIMIT_PER_MINUTE = parseInt(process.env.KNOX_API_RATE_LIMIT || '1000'); const API_CALL_DELAY_MS = Math.max( parseInt(process.env.KNOX_API_CALL_DELAY_MS || '100'), Math.ceil(60000 / RATE_LIMIT_PER_MINUTE) // 분당 제한에서 자동 계산 ); // Knox API 시간대 제한 강제 적용 여부 (테스트용) const FORCE_TIME_LIMIT = process.env.KNOX_API_FORCE_LIMIT === 'true'; // API 호출 제한을 위한 지연 함수 (분당 1000건 제한 준수) const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); async function upsertEmployees(employees: Employee[]) { if (!employees.length) return; const rows = employees.map((e) => ({ epId: e.epId, employeeNumber: e.employeeNumber, userId: e.userId, fullName: e.fullName, givenName: e.givenName, sirName: e.sirName, companyCode: e.companyCode, companyName: e.companyName, departmentCode: e.departmentCode, departmentName: e.departmentName, titleCode: e.titleCode, titleName: e.titleName, emailAddress: e.emailAddress, mobile: e.mobile, employeeStatus: e.employeeStatus, employeeType: e.employeeType, accountStatus: e.accountStatus, securityLevel: e.securityLevel, preferredLanguage: e.preferredLanguage, description: e.description, raw: e as unknown as Record, enCompanyName: e.enCompanyName, enDepartmentName: e.enDepartmentName, enDiscription: e.enDiscription, enFullName: e.enFullName, enGivenName: e.enGivenName, enGradeName: e.enGradeName, enSirName: e.enSirName, enTitleName: e.enTitleName, gradeName: e.gradeName, gradeTitleIndiCode: e.gradeTitleIndiCode, jobName: e.jobName, realNameYn: e.realNameYn, serverLocation: e.serverLocation, titleSortOrder: e.titleSortOrder, })); await db .insert(employeeTable) .values(rows) .onConflictDoUpdate({ target: employeeTable.epId, set: { fullName: sql.raw('excluded.full_name'), givenName: sql.raw('excluded.given_name'), sirName: sql.raw('excluded.sir_name'), companyCode: sql.raw('excluded.company_code'), companyName: sql.raw('excluded.company_name'), departmentCode: sql.raw('excluded.department_code'), departmentName: sql.raw('excluded.department_name'), titleCode: sql.raw('excluded.title_code'), titleName: sql.raw('excluded.title_name'), emailAddress: sql.raw('excluded.email_address'), mobile: sql.raw('excluded.mobile'), employeeStatus: sql.raw('excluded.employee_status'), employeeType: sql.raw('excluded.employee_type'), accountStatus: sql.raw('excluded.account_status'), securityLevel: sql.raw('excluded.security_level'), preferredLanguage: sql.raw('excluded.preferred_language'), description: sql.raw('excluded.description'), raw: sql.raw('excluded.raw'), updatedAt: sql.raw('CURRENT_TIMESTAMP'), }, }); } /** * 회사별 임직원 동기화 - 최적화된 버전 * 부서별 개별 호출 대신 회사 코드만으로 전체 조회하여 API 호출 수 최소화 */ async function syncEmployeesByCompany(companyCode: string): Promise { let totalApiCalls = 0; let totalEmployees = 0; try { let page = 1; let totalPage = 1; console.log(`[KNOX-SYNC] ${companyCode}: 임직원 동기화 시작`); do { // Rate limiting을 위한 지연 if (totalApiCalls > 0) { await delay(API_CALL_DELAY_MS); } const resp = await searchEmployees({ companyCode, page: String(page), resultType: 'basic', }); totalApiCalls++; if (resp.result === 'success') { await upsertEmployees(resp.employees); totalPage = resp.totalPage; totalEmployees += resp.employees.length; console.log( `[KNOX-SYNC] ${companyCode}: ${page}/${totalPage} 페이지 처리 완료 (${resp.employees.length}명, 누적: ${totalEmployees}명)` ); } else { console.warn( `[KNOX-SYNC] ${companyCode}: 페이지 ${page} 조회 실패` ); break; } page += 1; } while (page <= totalPage); console.log(`[KNOX-SYNC] ${companyCode}: 완료 - ${totalEmployees}명, API 호출 ${totalApiCalls}회`); } catch (err) { console.error( `[KNOX-SYNC] ${companyCode}: 동기화 오류 (API 호출 ${totalApiCalls}회)`, err ); } return totalApiCalls; } /** * Knox Employee 데이터를 Users 테이블과 동기화 * Knox 임직원 동기화 완료 후 실행되는 함수 */ async function syncEmployeesToUsers(): Promise { try { console.log('[KNOX-SYNC] Users 테이블 동기화 시작'); // Knox employee 테이블에서 이메일이 있는 레코드들 조회 const employees = await db .select({ fullName: employeeTable.fullName, emailAddress: employeeTable.emailAddress, departmentCode: employeeTable.departmentCode, departmentName: employeeTable.departmentName, companyCode: employeeTable.companyCode, }) .from(employeeTable) .where( and( sql`${employeeTable.emailAddress} IS NOT NULL`, sql`${employeeTable.emailAddress} != ''` ) ); console.log(`[KNOX-SYNC] 동기화 대상 Knox 임직원: ${employees.length}명`); if (employees.length === 0) { console.log('[KNOX-SYNC] 동기화할 Knox 임직원이 없습니다.'); return; } // 부서별 도메인 할당 정보 조회 (캐싱을 위해 한 번에 조회) const domainAssignments = await db .select({ companyCode: departmentDomainAssignments.companyCode, departmentCode: departmentDomainAssignments.departmentCode, assignedDomain: departmentDomainAssignments.assignedDomain, }) .from(departmentDomainAssignments) .where(eq(departmentDomainAssignments.isActive, true)); // 부서별 도메인 매핑을 위한 Map 생성 const domainMap = new Map(); domainAssignments.forEach(assignment => { const key = `${assignment.companyCode}-${assignment.departmentCode}`; domainMap.set(key, assignment.assignedDomain); }); let insertCount = 0; let updateCount = 0; let skipCount = 0; // 각 임직원에 대해 동기화 처리 for (const employee of employees) { if (!employee.emailAddress || !employee.fullName) { skipCount++; continue; } try { // 부서별 도메인 할당 정보 조회 const domainKey = `${employee.companyCode}-${employee.departmentCode}`; const assignedDomain = domainMap.get(domainKey) || 'pending'; // 기존 사용자 확인 (partners가 아닌 사용자만 대상) const existingUsers = await db .select({ id: users.id, domain: users.domain }) .from(users) .where( and( eq(users.email, employee.emailAddress), ne(users.domain, 'partners') ) ) .limit(1); if (existingUsers.length > 0) { // 기존 사용자 업데이트 await db .update(users) .set({ name: employee.fullName, deptCode: employee.departmentCode, deptName: employee.departmentName, domain: assignedDomain as UserDomainType, updatedAt: new Date(), }) .where(eq(users.id, existingUsers[0].id)); updateCount++; } else { // 새 사용자 생성 (partners 도메인 사용자가 아닌 경우에만) // 먼저 해당 이메일이 이미 존재하는지 확인 const anyExistingUser = await db .select({ id: users.id }) .from(users) .where(eq(users.email, employee.emailAddress)) .limit(1); if (anyExistingUser.length === 0) { // 완전히 새로운 사용자 생성 await db .insert(users) .values({ name: employee.fullName, email: employee.emailAddress, deptCode: employee.departmentCode, deptName: employee.departmentName, domain: assignedDomain as UserDomainType, }); insertCount++; } else { // partners 도메인 사용자는 스킵 skipCount++; } } } catch (error) { console.error(`[KNOX-SYNC] 사용자 동기화 실패 (${employee.emailAddress}):`, error); skipCount++; } } console.log(`[KNOX-SYNC] Users 테이블 동기화 완료`); console.log(`[KNOX-SYNC] - 신규 생성: ${insertCount}명`); console.log(`[KNOX-SYNC] - 업데이트: ${updateCount}명`); console.log(`[KNOX-SYNC] - 스킵: ${skipCount}명`); } catch (error) { console.error('[KNOX-SYNC] Users 테이블 동기화 오류:', error); throw error; } } export async function syncKnoxEmployees(): Promise { console.log('[KNOX-SYNC] Knox 임직원 동기화 시작'); console.log(`[KNOX-SYNC] 대상 회사: ${COMPANIES.join(', ')}`); let totalApiCalls = 0; const startTime = Date.now(); try { // 각 회사별 순차 처리로 API 호출 제한 준수 for (const companyCode of COMPANIES) { const apiCalls = await syncEmployeesByCompany(companyCode); totalApiCalls += apiCalls; // 일일 호출 제한(100건) 근접 시 경고 if (totalApiCalls >= DAILY_API_LIMIT - 10) { // 일일 제한에 가까워지면 경고 console.warn(`[KNOX-SYNC] ⚠️ API 호출 ${totalApiCalls}회 - 일일 제한(${DAILY_API_LIMIT}건) 근접!`); } // 일일 제한 초과 방지 if (totalApiCalls >= DAILY_API_LIMIT) { console.error(`[KNOX-SYNC] 🚨 일일 API 호출 제한(${DAILY_API_LIMIT}건) 초과로 중단`); break; } } const duration = Math.round((Date.now() - startTime) / 1000); console.log(`[KNOX-SYNC] 임직원 동기화 완료 - 총 ${totalApiCalls}회 API 호출, ${duration}초 소요`); // Knox Employee 동기화 완료 후 Users 테이블 동기화 실행 await syncEmployeesToUsers(); } catch (error) { console.error('[KNOX-SYNC] 동기화 중 오류 발생:', error); throw error; } } export async function startKnoxEmployeeSyncScheduler() { // 환경 변수에 따라 실행시 즉시 실행 여부 결정 (없으면 false) if (DO_FIRST_RUN) { // 현재 시간이 주간이면 경고 메시지 또는 실행 중단 (Knox API 가이드 권장사항) const currentHour = new Date().getHours(); if (currentHour >= 6 && currentHour < 22) { if (FORCE_TIME_LIMIT) { console.error('[KNOX-SYNC] 🚨 주간 시간대(06:00-22:00) 대량 호출 금지 - 야간에 실행하세요'); console.log('[KNOX-SYNC] 💡 테스트용 실행을 원하면 KNOX_API_FORCE_LIMIT 환경변수를 제거하세요'); return; } else { console.warn('[KNOX-SYNC] ⚠️ 주간 시간대 대량 호출 - Knox Portal과 사전 협의 권장 (테스트 모드)'); } } syncKnoxEmployees().catch(console.error); } // CRON JOB 등록 cron.schedule(CRON_STRING, () => { syncKnoxEmployees().catch(console.error); }); console.log(`[KNOX-SYNC] 임직원 동기화 스케줄러 등록 (${CRON_STRING})`); console.log(`[KNOX-SYNC] API 제한사항: 분당 ${RATE_LIMIT_PER_MINUTE}건, 일일 ${DAILY_API_LIMIT}건`); console.log(`[KNOX-SYNC] 호출 간격: ${API_CALL_DELAY_MS}ms`); console.log(`[KNOX-SYNC] Knox API 권장: 대량 호출 시 야간(22시 이후) 실행`); console.log(`[KNOX-SYNC] 시간대 제한 강제 적용: ${FORCE_TIME_LIMIT ? '활성화' : '비활성화 (테스트 모드)'}`); }