'use server'; import * as cron from 'node-cron'; import db from '@/db/db'; import { organization as organizationTable } from '@/db/schema/knox/organization'; import { searchOrganizations, Organization, } from '@/lib/knox-api/employee/employee'; import { sql } from 'drizzle-orm'; // 동기화 대상 회사 코드 (쉼표로 구분된 ENV) const COMPANIES = (process.env.KNOX_COMPANY_CODES || 'D60') .split(',') .map((c) => c.trim()) .filter(Boolean); // CRON 스케줄 (기본: 매일 04:15) const CRON_STRING = process.env.KNOX_ORGANIZATION_SYNC_CRON || '15 4 * * *'; // 애플리케이션 기동 시 최초 한 번 실행 여부 const DO_FIRST_RUN = process.env.KNOX_ORGANIZATION_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 upsertOrganizations(orgs: Organization[]) { if (!orgs.length) return; console.log(`[KNOX-SYNC] ${orgs.length}개 조직 정보 업서트 시작`); const startTime = Date.now(); const rows = orgs.map((o) => ({ companyCode: o.companyCode, departmentCode: o.departmentCode, companyName: o.companyName, departmentLevel: o.departmentLevel, departmentName: o.departmentName, departmentOrder: o.departmentOrder, enCompanyName: o.enCompanyName, enDepartmentName: o.enDepartmentName, enManagerTitle: o.enManagerTitle, enSubOrgCode: o.enSubOrgCode, inDepartmentCode: o.inDepartmentCode, lowDepartmentYn: o.lowDepartmentYn, managerId: o.managerId, managerName: o.managerName, managerTitle: o.managerTitle, preferredLanguage: o.preferredLanguage, subOrgCode: o.subOrgCode, subOrgName: o.subOrgName, uprDepartmentCode: o.uprDepartmentCode, enUprDepartmentName: o.enUprDepartmentName, uprDepartmentName: o.uprDepartmentName, hiddenDepartmentYn: o.hiddenDepartmentYn, corpCode: o.corpCode, corpName: o.corpName, enCorpName: o.enCorpName, raw: o as unknown as Record, })); await db .insert(organizationTable) .values(rows) .onConflictDoUpdate({ target: [organizationTable.companyCode, organizationTable.departmentCode], set: { companyName: sql.raw('excluded.company_name'), departmentLevel: sql.raw('excluded.department_level'), departmentName: sql.raw('excluded.department_name'), departmentOrder: sql.raw('excluded.department_order'), enCompanyName: sql.raw('excluded.en_company_name'), enDepartmentName: sql.raw('excluded.en_department_name'), enManagerTitle: sql.raw('excluded.en_manager_title'), enSubOrgCode: sql.raw('excluded.en_sub_org_code'), inDepartmentCode: sql.raw('excluded.in_department_code'), lowDepartmentYn: sql.raw('excluded.low_department_yn'), managerId: sql.raw('excluded.manager_id'), managerName: sql.raw('excluded.manager_name'), managerTitle: sql.raw('excluded.manager_title'), preferredLanguage: sql.raw('excluded.preferred_language'), subOrgCode: sql.raw('excluded.sub_org_code'), subOrgName: sql.raw('excluded.sub_org_name'), uprDepartmentCode: sql.raw('excluded.upr_department_code'), enUprDepartmentName: sql.raw('excluded.en_upr_department_name'), uprDepartmentName: sql.raw('excluded.upr_department_name'), hiddenDepartmentYn: sql.raw('excluded.hidden_department_yn'), corpCode: sql.raw('excluded.corp_code'), corpName: sql.raw('excluded.corp_name'), enCorpName: sql.raw('excluded.en_corp_name'), raw: sql.raw('excluded.raw'), updatedAt: sql.raw('CURRENT_TIMESTAMP'), }, }); const endTime = Date.now(); console.log(`[KNOX-SYNC] ${orgs.length}개 조직 정보 업서트 완료 (소요시간: ${endTime - startTime}ms)`); } /** * 회사별 조직 동기화 - 최적화된 버전 * Knox API 가이드에 따라 첫 번째 호출은 페이지 없이 최대 2,000건 조회 * 그보다 많은 경우에만 페이징 처리하여 API 호출 수 최소화 */ async function syncOrganizationsByCompany(companyCode: string): Promise { let totalApiCalls = 0; let totalOrganizations = 0; try { console.log(`[KNOX-SYNC] ${companyCode}: 조직 동기화 시작`); // 첫 번째 호출: 페이지 없이 최대 2,000건까지 조회 (Knox API 가이드 기준) const firstResp = await searchOrganizations({ companyCode }); totalApiCalls++; if (firstResp.result === 'success') { await upsertOrganizations(firstResp.organizations); totalOrganizations += firstResp.organizations.length; console.log( `[KNOX-SYNC] ${companyCode}: 첫 번째 조회 완료 - 총 ${firstResp.totalCount}개 중 ${firstResp.organizations.length}개 처리 (${firstResp.totalPage} 페이지)` ); // 2,000개를 초과하는 경우 나머지 페이지 처리 if (firstResp.totalPage > 1) { console.log(`[KNOX-SYNC] ${companyCode}: ${firstResp.totalPage - 1}개 추가 페이지 처리 시작`); for (let page = 2; page <= firstResp.totalPage; page++) { // Rate limiting을 위한 지연 await delay(API_CALL_DELAY_MS); const resp = await searchOrganizations({ companyCode, page: String(page) }); totalApiCalls++; if (resp.result === 'success') { await upsertOrganizations(resp.organizations); totalOrganizations += resp.organizations.length; console.log( `[KNOX-SYNC] ${companyCode}: ${page}/${resp.totalPage} 페이지 처리 완료 (${resp.organizations.length}개, 누적: ${totalOrganizations}개)` ); } else { console.warn(`[KNOX-SYNC] ${companyCode}: 페이지 ${page} 조회 실패`); break; } } } console.log(`[KNOX-SYNC] ${companyCode}: 완료 - ${totalOrganizations}개 조직, API 호출 ${totalApiCalls}회`); } else { console.warn(`[KNOX-SYNC] ${companyCode}: 첫 번째 조회 실패`); } } catch (err) { console.error( `[KNOX-SYNC] ${companyCode}: 동기화 오류 (API 호출 ${totalApiCalls}회)`, err ); } return totalApiCalls; } export async function syncKnoxOrganizations(): Promise { console.log('[KNOX-SYNC] Knox 조직 동기화 시작'); console.log(`[KNOX-SYNC] 대상 회사: ${COMPANIES.join(', ')}`); let totalApiCalls = 0; const startTime = Date.now(); // 각 회사별 순차 처리로 API 호출 제한 준수 for (const companyCode of COMPANIES) { const apiCalls = await syncOrganizationsByCompany(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}초 소요`); } export async function startKnoxOrganizationSyncScheduler() { // 환경 변수에 따라 실행시 즉시 실행 여부 결정 (없으면 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과 사전 협의 권장 (테스트 모드)'); } } syncKnoxOrganizations().catch(console.error); } // CRON JOB 등록 cron.schedule(CRON_STRING, () => { syncKnoxOrganizations().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 ? '활성화' : '비활성화 (테스트 모드)'}`); }