diff options
| author | joonhoekim <26rote@gmail.com> | 2025-07-24 10:32:34 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-07-24 10:32:34 +0000 |
| commit | ed0d6fcc98f671280c2ccde797b50693da88152e (patch) | |
| tree | 6ea4bc8b13546fbd9de949a378fd2efb22c9cbdd /lib | |
| parent | a50bc9baea332f996e6bc3a5d70c69f6d2d0f194 (diff) | |
(김준회) knox 임직원, 조직도, 직급 동기화 로직 수정 (delete & insert) 및 시간당 호출제한 반영, SSO 운영 변경 대응 (버튼 텍스트에 Stage 인 경우에만 Stage 표시), 부서 트리뷰에서 '하위' 뱃지 제거
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/knox-sync/common.ts | 162 | ||||
| -rw-r--r-- | lib/knox-sync/employee-sync-service.ts | 227 | ||||
| -rw-r--r-- | lib/knox-sync/organization-sync-service.ts | 167 | ||||
| -rw-r--r-- | lib/knox-sync/title-sync-service.ts | 87 |
4 files changed, 397 insertions, 246 deletions
diff --git a/lib/knox-sync/common.ts b/lib/knox-sync/common.ts new file mode 100644 index 00000000..0b027a4f --- /dev/null +++ b/lib/knox-sync/common.ts @@ -0,0 +1,162 @@ +// Knox API 공통 설정 및 유틸리티 함수들 + +// 동기화 대상 회사 코드 (쉼표로 구분된 ENV) +export const KNOX_COMPANIES = (process.env.KNOX_COMPANY_CODES || 'D60') + .split(',') + .map((c) => c.trim()) + .filter(Boolean); + +// API 호출 제한 설정 (환경변수로 제어 가능) +export const HOURLY_API_LIMIT = parseInt(process.env.KNOX_API_HOURLY_LIMIT || '90'); +export const API_CALL_DELAY_MS = Math.max( + parseInt(process.env.KNOX_API_CALL_DELAY_MS || '0'), + Math.ceil(3600000 / HOURLY_API_LIMIT) // 시간당 제한에서 자동 계산 (3600초 = 1시간) +); + +// Knox API 시간대 제한 강제 적용 여부 (테스트용) +export const FORCE_TIME_LIMIT = process.env.KNOX_API_FORCE_LIMIT === 'true'; + +// API 호출 시간 추적을 위한 인터페이스 +interface ApiCallTracker { + startTime: number; + currentHourCalls: number; + lastResetTime: number; +} + +// 전역 API 호출 추적기 +let apiTracker: ApiCallTracker = { + startTime: Date.now(), + currentHourCalls: 0, + lastResetTime: Date.now() +}; + +// API 호출 제한을 위한 지연 함수 (시간당 제한 준수) +export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +/** + * Knox API 시간대 제한 체크 + * 주간 시간대(06:00-22:00)에 실행 시 경고 또는 중단 + */ +export function checkTimeRestriction(): boolean { + 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 false; // 실행 중단 + } else { + console.warn('[KNOX-SYNC] ⚠️ 주간 시간대 대량 호출 - Knox Portal과 사전 협의 권장 (테스트 모드)'); + return true; // 경고만 출력하고 계속 실행 + } + } + return true; // 야간 시간대는 정상 실행 +} + +/** + * API 호출 제한 상태 체크 및 동적 delay 조절 + * 실제 시간 기반으로 1시간마다 리셋, 강제 중단 없이 delay만 조절 + */ +export function checkApiLimitStatus(newApiCalls: number = 1): { + shouldWarn: boolean; + recommendedDelay: number; + resetInfo?: string; +} { + const now = Date.now(); + const hoursPassed = (now - apiTracker.lastResetTime) / (1000 * 60 * 60); + + // 1시간이 지났으면 카운터 리셋 + if (hoursPassed >= 1.0) { + const resetInfo = `[KNOX-SYNC] 📊 API 제한 리셋 - 이전 시간: ${apiTracker.currentHourCalls}회 → 0회`; + apiTracker.currentHourCalls = 0; + apiTracker.lastResetTime = now; + console.log(resetInfo); + } + + // 새로운 API 호출 추가 + apiTracker.currentHourCalls += newApiCalls; + + const shouldWarn = apiTracker.currentHourCalls >= HOURLY_API_LIMIT - 10; + + // 제한 근접 시 delay 동적 조절 + let recommendedDelay = API_CALL_DELAY_MS; + + if (apiTracker.currentHourCalls >= HOURLY_API_LIMIT) { + // 제한 초과 시 더 긴 delay (남은 시간을 남은 호출로 분배) + const remainingTimeMs = (1000 * 60 * 60) - (now - apiTracker.lastResetTime); + const estimatedRemainingCalls = Math.max(10, HOURLY_API_LIMIT * 0.1); // 최소 10회는 남겨둠 + recommendedDelay = Math.max(recommendedDelay, Math.ceil(remainingTimeMs / estimatedRemainingCalls)); + + console.warn(`[KNOX-SYNC] ⚠️ 시간당 제한(${HOURLY_API_LIMIT}회) 초과! delay를 ${recommendedDelay}ms로 증가`); + } else if (shouldWarn) { + // 제한 근접 시 적당한 delay 증가 + recommendedDelay = Math.max(recommendedDelay, API_CALL_DELAY_MS * 2); + console.warn(`[KNOX-SYNC] ⚠️ API 호출 ${apiTracker.currentHourCalls}회 - 시간당 제한(${HOURLY_API_LIMIT}회) 근접! delay 증가`); + } + + // 현재 상태 로그 (10회마다) + if (apiTracker.currentHourCalls % 10 === 0 && apiTracker.currentHourCalls > 0) { + const elapsed = Math.round((now - apiTracker.lastResetTime) / (1000 * 60)); + console.log(`[KNOX-SYNC] 📊 현재 시간대 API 호출: ${apiTracker.currentHourCalls}/${HOURLY_API_LIMIT}회 (경과시간: ${elapsed}분)`); + } + + return { + shouldWarn, + recommendedDelay, + resetInfo: hoursPassed >= 1.0 ? `리셋됨 (${Math.round(hoursPassed * 10) / 10}시간 경과)` : undefined + }; +} + +/** + * 동기화 시작 시 API 추적기 초기화 + */ +export function initializeApiTracker(): void { + apiTracker = { + startTime: Date.now(), + currentHourCalls: 0, + lastResetTime: Date.now() + }; + console.log('[KNOX-SYNC] 📊 API 호출 추적기 초기화 완료'); +} + +/** + * 현재 API 사용량 정보 반환 + */ +export function getApiUsageInfo(): { currentCalls: number; limit: number; timeUntilReset: number } { + const now = Date.now(); + const timeUntilReset = (1000 * 60 * 60) - (now - apiTracker.lastResetTime); + + return { + currentCalls: apiTracker.currentHourCalls, + limit: HOURLY_API_LIMIT, + timeUntilReset: Math.max(0, timeUntilReset) + }; +} + +/** + * 동기화 시작 로그 출력 + */ +export function logSyncStart(syncType: string): void { + console.log(`[KNOX-SYNC] Knox ${syncType} 동기화 시작`); + console.log(`[KNOX-SYNC] 대상 회사: ${KNOX_COMPANIES.join(', ')}`); +} + +/** + * 동기화 완료 로그 출력 + */ +export function logSyncComplete(syncType: string, totalApiCalls: number, startTime: number): void { + const duration = Math.round((Date.now() - startTime) / 1000); + const usage = getApiUsageInfo(); + console.log(`[KNOX-SYNC] ${syncType} 동기화 완료 - 총 ${totalApiCalls}회 API 호출, ${duration}초 소요`); + console.log(`[KNOX-SYNC] 📊 현재 시간대 사용량: ${usage.currentCalls}/${usage.limit}회`); +} + +/** + * 스케줄러 등록 로그 출력 + */ +export function logSchedulerInfo(syncType: string, cronString: string): void { + console.log(`[KNOX-SYNC] ${syncType} 동기화 스케줄러 등록 (${cronString})`); + console.log(`[KNOX-SYNC] API 제한사항: 시간당 ${HOURLY_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 ? '활성화' : '비활성화 (테스트 모드)'}`); +}
\ No newline at end of file diff --git a/lib/knox-sync/employee-sync-service.ts b/lib/knox-sync/employee-sync-service.ts index 282c493b..3e8b048e 100644 --- a/lib/knox-sync/employee-sync-service.ts +++ b/lib/knox-sync/employee-sync-service.ts @@ -3,6 +3,7 @@ import * as cron from 'node-cron'; import db from '@/db/db'; import { employee as employeeTable } from '@/db/schema/knox/employee'; +import { title as titleTable } from '@/db/schema/knox/titles'; import { users, type UserDomainType } from '@/db/schema/users'; import { departmentDomainAssignments } from '@/db/schema/departmentDomainAssignments'; import { @@ -10,32 +11,22 @@ import { 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); +import { + KNOX_COMPANIES, + delay, + API_CALL_DELAY_MS, + checkTimeRestriction, + checkApiLimitStatus, + initializeApiTracker, + logSyncStart, + logSyncComplete, + logSchedulerInfo, +} from './common'; 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[]) { +async function insertEmployees(employees: Employee[]) { if (!employees.length) return; const rows = employees.map((e) => ({ @@ -76,89 +67,119 @@ async function upsertEmployees(employees: Employee[]) { 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'), - }, - }); + await db.insert(employeeTable).values(rows); } /** - * 회사별 임직원 동기화 - 최적화된 버전 - * 부서별 개별 호출 대신 회사 코드만으로 전체 조회하여 API 호출 수 최소화 + * 회사별 기존 임직원 데이터 삭제 + */ +async function deleteExistingEmployees(companyCode: string): Promise<number> { + const result = await db + .delete(employeeTable) + .where(eq(employeeTable.companyCode, companyCode)); + + const deletedCount = result.rowCount || 0; + console.log(`[KNOX-SYNC] ${companyCode}: 기존 임직원 데이터 ${deletedCount}건 삭제 완료`); + return deletedCount; +} + +/** + * 회사별 임직원 동기화 - 직급별 조회 방식 (Delete-Insert) + * 기존 데이터를 모두 삭제한 후 새로운 데이터를 삽입하여 삭제된 데이터 문제 해결 */ async function syncEmployeesByCompany(companyCode: string): Promise<number> { - let totalApiCalls = 0; + let currentApiCalls = 0; let totalEmployees = 0; + let currentDelay = API_CALL_DELAY_MS; try { - let page = 1; - let totalPage = 1; + console.log(`[KNOX-SYNC] ${companyCode}: 임직원 동기화 시작 (Delete-Insert 방식)`); - console.log(`[KNOX-SYNC] ${companyCode}: 임직원 동기화 시작`); + // 1단계: 기존 데이터 삭제 + await deleteExistingEmployees(companyCode); - do { - // Rate limiting을 위한 지연 - if (totalApiCalls > 0) { - await delay(API_CALL_DELAY_MS); - } + // 2단계: 내부 DB에서 해당 회사의 직급 목록 조회 (이미 동기화된 데이터 활용) + console.log(`[KNOX-SYNC] ${companyCode}: 내부 DB에서 직급 목록 조회 중...`); + + const titles = await db + .select({ + titleCode: titleTable.titleCode, + titleName: titleTable.titleName, + sortOrder: titleTable.sortOrder, + }) + .from(titleTable) + .where(eq(titleTable.companyCode, companyCode)) + .orderBy(titleTable.sortOrder); - 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}: 총 ${titles.length}개 직급 발견`); + + if (titles.length === 0) { + console.warn(`[KNOX-SYNC] ${companyCode}: 직급 정보가 없습니다. 직급 동기화를 먼저 실행해주세요.`); + return 0; + } + + // 3단계: 각 직급별로 임직원 조회 + for (const title of titles) { + let empPage = 1; + let empTotalPage = 1; + let titleEmployeeCount = 0; + + do { + // 동적 delay 적용 + if (currentApiCalls > 0) { + await delay(currentDelay); + } + + const empResp = await searchEmployees({ + companyCode, + titleCode: title.titleCode, + page: String(empPage), + resultType: 'basic', + }); + // API 제한 체크 및 delay 조절 + const limitStatus = checkApiLimitStatus(1); + currentDelay = limitStatus.recommendedDelay; + currentApiCalls++; + + if (empResp.result === 'success') { + await insertEmployees(empResp.employees); + empTotalPage = empResp.totalPage; + totalEmployees += empResp.employees.length; + titleEmployeeCount += empResp.employees.length; + + if (empResp.employees.length > 0) { + console.log( + `[KNOX-SYNC] ${companyCode}: ${title.titleName}(${title.titleCode}) ${empPage}/${empTotalPage} 페이지 완료 (${empResp.employees.length}명)` + ); + } + } else { + console.warn( + `[KNOX-SYNC] ${companyCode}: ${title.titleName}(${title.titleCode}) 페이지 ${empPage} 조회 실패` + ); + break; + } + + empPage += 1; + } while (empPage <= empTotalPage); + + // 직급별 완료 로그 (임직원이 있는 경우만) + if (titleEmployeeCount > 0) { console.log( - `[KNOX-SYNC] ${companyCode}: ${page}/${totalPage} 페이지 처리 완료 (${resp.employees.length}명, 누적: ${totalEmployees}명)` - ); - } else { - console.warn( - `[KNOX-SYNC] ${companyCode}: 페이지 ${page} 조회 실패` + `[KNOX-SYNC] ${companyCode}: ${title.titleName}(${title.titleCode}) 완료 - ${titleEmployeeCount}명` ); - break; } + } - page += 1; - } while (page <= totalPage); - - console.log(`[KNOX-SYNC] ${companyCode}: 완료 - ${totalEmployees}명, API 호출 ${totalApiCalls}회`); + console.log(`[KNOX-SYNC] ${companyCode}: 완료 - ${totalEmployees}명, API 호출 ${currentApiCalls}회`); } catch (err) { console.error( - `[KNOX-SYNC] ${companyCode}: 동기화 오류 (API 호출 ${totalApiCalls}회)`, + `[KNOX-SYNC] ${companyCode}: 동기화 오류 (API 호출 ${currentApiCalls}회)`, err ); } - return totalApiCalls; + return currentApiCalls; } /** @@ -300,32 +321,20 @@ async function syncEmployeesToUsers(): Promise<void> { } export async function syncKnoxEmployees(): Promise<void> { - console.log('[KNOX-SYNC] Knox 임직원 동기화 시작'); - console.log(`[KNOX-SYNC] 대상 회사: ${COMPANIES.join(', ')}`); + logSyncStart('임직원'); + initializeApiTracker(); // API 추적기 초기화 let totalApiCalls = 0; const startTime = Date.now(); try { - // 각 회사별 순차 처리로 API 호출 제한 준수 - for (const companyCode of COMPANIES) { + // 각 회사별 순차 처리 + for (const companyCode of KNOX_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}초 소요`); + logSyncComplete('임직원', totalApiCalls, startTime); // Knox Employee 동기화 완료 후 Users 테이블 동기화 실행 await syncEmployeesToUsers(); @@ -339,16 +348,8 @@ export async function syncKnoxEmployees(): Promise<void> { 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과 사전 협의 권장 (테스트 모드)'); - } + if (!checkTimeRestriction()) { + return; } syncKnoxEmployees().catch(console.error); @@ -359,9 +360,5 @@ export async function startKnoxEmployeeSyncScheduler() { 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 ? '활성화' : '비활성화 (테스트 모드)'}`); + logSchedulerInfo('임직원', CRON_STRING); } diff --git a/lib/knox-sync/organization-sync-service.ts b/lib/knox-sync/organization-sync-service.ts index 8ce9c0ca..6ee72b13 100644 --- a/lib/knox-sync/organization-sync-service.ts +++ b/lib/knox-sync/organization-sync-service.ts @@ -7,13 +7,18 @@ 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); +import { eq } from 'drizzle-orm'; +import { + KNOX_COMPANIES, + delay, + API_CALL_DELAY_MS, + checkTimeRestriction, + checkApiLimitStatus, + initializeApiTracker, + logSyncStart, + logSyncComplete, + logSchedulerInfo, +} from './common'; // CRON 스케줄 (기본: 매일 04:15) const CRON_STRING = process.env.KNOX_ORGANIZATION_SYNC_CRON || '15 4 * * *'; @@ -21,24 +26,10 @@ 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[]) { +async function insertOrganizations(orgs: Organization[]) { if (!orgs.length) return; - console.log(`[KNOX-SYNC] ${orgs.length}개 조직 정보 업서트 시작`); + console.log(`[KNOX-SYNC] ${orgs.length}개 조직 정보 삽입 시작`); const startTime = Date.now(); const rows = orgs.map((o) => ({ @@ -70,62 +61,50 @@ async function upsertOrganizations(orgs: Organization[]) { raw: o as unknown as Record<string, unknown>, })); - 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'), - }, - }); + await db.insert(organizationTable).values(rows); const endTime = Date.now(); - console.log(`[KNOX-SYNC] ${orgs.length}개 조직 정보 업서트 완료 (소요시간: ${endTime - startTime}ms)`); + console.log(`[KNOX-SYNC] ${orgs.length}개 조직 정보 삽입 완료 (소요시간: ${endTime - startTime}ms)`); +} + +/** + * 회사별 기존 조직 데이터 삭제 + */ +async function deleteExistingOrganizations(companyCode: string): Promise<number> { + const result = await db + .delete(organizationTable) + .where(eq(organizationTable.companyCode, companyCode)); + + const deletedCount = result.rowCount || 0; + console.log(`[KNOX-SYNC] ${companyCode}: 기존 조직 데이터 ${deletedCount}건 삭제 완료`); + return deletedCount; } /** - * 회사별 조직 동기화 - 최적화된 버전 - * Knox API 가이드에 따라 첫 번째 호출은 페이지 없이 최대 2,000건 조회 - * 그보다 많은 경우에만 페이징 처리하여 API 호출 수 최소화 + * 회사별 조직 동기화 - Delete-Insert 방식 + * 기존 데이터를 모두 삭제한 후 새로운 데이터를 삽입하여 삭제된 데이터 문제 해결 */ async function syncOrganizationsByCompany(companyCode: string): Promise<number> { - let totalApiCalls = 0; + let currentApiCalls = 0; let totalOrganizations = 0; + let currentDelay = API_CALL_DELAY_MS; try { - console.log(`[KNOX-SYNC] ${companyCode}: 조직 동기화 시작`); + console.log(`[KNOX-SYNC] ${companyCode}: 조직 동기화 시작 (Delete-Insert 방식)`); + + // 1단계: 기존 데이터 삭제 + await deleteExistingOrganizations(companyCode); - // 첫 번째 호출: 페이지 없이 최대 2,000건까지 조회 (Knox API 가이드 기준) + // 2단계: 첫 번째 호출 - 페이지 없이 최대 2,000건까지 조회 (Knox API 가이드 기준) const firstResp = await searchOrganizations({ companyCode }); - totalApiCalls++; + + // API 제한 체크 및 delay 조절 + const limitStatus = checkApiLimitStatus(1); + currentDelay = limitStatus.recommendedDelay; + currentApiCalls++; if (firstResp.result === 'success') { - await upsertOrganizations(firstResp.organizations); + await insertOrganizations(firstResp.organizations); totalOrganizations += firstResp.organizations.length; console.log( @@ -137,14 +116,18 @@ async function syncOrganizationsByCompany(companyCode: string): Promise<number> console.log(`[KNOX-SYNC] ${companyCode}: ${firstResp.totalPage - 1}개 추가 페이지 처리 시작`); for (let page = 2; page <= firstResp.totalPage; page++) { - // Rate limiting을 위한 지연 - await delay(API_CALL_DELAY_MS); + // 동적 delay 적용 + await delay(currentDelay); const resp = await searchOrganizations({ companyCode, page: String(page) }); - totalApiCalls++; + + // API 제한 체크 및 delay 조절 + const pageLimit = checkApiLimitStatus(1); + currentDelay = pageLimit.recommendedDelay; + currentApiCalls++; if (resp.result === 'success') { - await upsertOrganizations(resp.organizations); + await insertOrganizations(resp.organizations); totalOrganizations += resp.organizations.length; console.log( @@ -157,61 +140,41 @@ async function syncOrganizationsByCompany(companyCode: string): Promise<number> } } - console.log(`[KNOX-SYNC] ${companyCode}: 완료 - ${totalOrganizations}개 조직, API 호출 ${totalApiCalls}회`); + console.log(`[KNOX-SYNC] ${companyCode}: 완료 - ${totalOrganizations}개 조직, API 호출 ${currentApiCalls}회`); } else { console.warn(`[KNOX-SYNC] ${companyCode}: 첫 번째 조회 실패`); } } catch (err) { console.error( - `[KNOX-SYNC] ${companyCode}: 동기화 오류 (API 호출 ${totalApiCalls}회)`, + `[KNOX-SYNC] ${companyCode}: 동기화 오류 (API 호출 ${currentApiCalls}회)`, err ); } - return totalApiCalls; + return currentApiCalls; } export async function syncKnoxOrganizations(): Promise<void> { - console.log('[KNOX-SYNC] Knox 조직 동기화 시작'); - console.log(`[KNOX-SYNC] 대상 회사: ${COMPANIES.join(', ')}`); + logSyncStart('조직'); + initializeApiTracker(); // API 추적기 초기화 let totalApiCalls = 0; const startTime = Date.now(); - // 각 회사별 순차 처리로 API 호출 제한 준수 - for (const companyCode of COMPANIES) { + // 각 회사별 순차 처리 + for (const companyCode of KNOX_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}초 소요`); + logSyncComplete('조직', totalApiCalls, startTime); } 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과 사전 협의 권장 (테스트 모드)'); - } + if (!checkTimeRestriction()) { + return; } syncKnoxOrganizations().catch(console.error); @@ -222,9 +185,5 @@ export async function startKnoxOrganizationSyncScheduler() { 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 ? '활성화' : '비활성화 (테스트 모드)'}`); + logSchedulerInfo('조직', CRON_STRING); }
\ No newline at end of file diff --git a/lib/knox-sync/title-sync-service.ts b/lib/knox-sync/title-sync-service.ts index e7bc13bd..5a8ce42a 100644 --- a/lib/knox-sync/title-sync-service.ts +++ b/lib/knox-sync/title-sync-service.ts @@ -4,17 +4,23 @@ import * as cron from 'node-cron'; import db from '@/db/db'; import { title as titleTable } from '@/db/schema/knox/titles'; import { getTitlesByCompany, Title } from '@/lib/knox-api/employee/employee'; -import { sql } from 'drizzle-orm'; - -const COMPANIES = (process.env.KNOX_COMPANY_CODES || 'P2') - .split(',') - .map((c) => c.trim()) - .filter(Boolean); +import { eq } from 'drizzle-orm'; +import { + KNOX_COMPANIES, + delay, + API_CALL_DELAY_MS, + checkTimeRestriction, + checkApiLimitStatus, + initializeApiTracker, + logSyncStart, + logSyncComplete, + logSchedulerInfo, +} from './common'; const CRON_STRING = process.env.KNOX_TITLE_SYNC_CRON || '30 4 * * *'; const DO_FIRST_RUN = process.env.KNOX_TITLE_SYNC_FIRST_RUN === 'true'; -async function upsertTitles(titles: Title[]) { +async function insertTitles(titles: Title[]) { if (!titles.length) return; const rows = titles.map((t) => ({ @@ -26,39 +32,66 @@ async function upsertTitles(titles: Title[]) { raw: t as unknown as Record<string, unknown>, })); - await db - .insert(titleTable) - .values(rows) - .onConflictDoUpdate({ - target: [titleTable.companyCode, titleTable.titleCode], - set: { - titleName: sql.raw('excluded.title_name'), - enTitleName: sql.raw('excluded.en_title_name'), - sortOrder: sql.raw('excluded.sort_order'), - raw: sql.raw('excluded.raw'), - updatedAt: sql.raw('CURRENT_TIMESTAMP'), - }, - }); + await db.insert(titleTable).values(rows); +} + +/** + * 회사별 기존 직급 데이터 삭제 + */ +async function deleteExistingTitles(companyCode: string): Promise<number> { + const result = await db + .delete(titleTable) + .where(eq(titleTable.companyCode, companyCode)); + + const deletedCount = result.rowCount || 0; + console.log(`[KNOX-SYNC] ${companyCode}: 기존 직급 데이터 ${deletedCount}건 삭제 완료`); + return deletedCount; } export async function syncKnoxTitles(): Promise<void> { - console.log('[KNOX-SYNC] 직급 동기화 시작'); + logSyncStart('직급'); + initializeApiTracker(); // API 추적기 초기화 + + let totalApiCalls = 0; + const startTime = Date.now(); + let currentDelay = API_CALL_DELAY_MS; - for (const companyCode of COMPANIES) { + for (const companyCode of KNOX_COMPANIES) { try { + console.log(`[KNOX-SYNC] ${companyCode}: 직급 동기화 시작 (Delete-Insert 방식)`); + + // 1단계: 기존 데이터 삭제 + await deleteExistingTitles(companyCode); + + // 2단계: 회사 간 딜레이 적용 (첫 번째 회사 제외) + if (totalApiCalls > 0) { + await delay(currentDelay); + } + + // 3단계: 새로운 직급 데이터 조회 및 삽입 const titles = await getTitlesByCompany(companyCode); - await upsertTitles(titles); - console.log(`[KNOX-SYNC] 직급 동기화 완료 - ${companyCode}: ${titles.length}건`); + + // API 제한 체크 및 delay 조절 + const limitStatus = checkApiLimitStatus(1); + currentDelay = limitStatus.recommendedDelay; + totalApiCalls++; + + await insertTitles(titles); + console.log(`[KNOX-SYNC] ${companyCode}: 직급 동기화 완료 - ${titles.length}건, API 호출 1회`); } catch (err) { - console.error(`[KNOX-SYNC] 직급 동기화 오류 (company ${companyCode})`, err); + console.error(`[KNOX-SYNC] ${companyCode}: 직급 동기화 오류`, err); } } - console.log('[KNOX-SYNC] 직급 동기화 전체 완료'); + logSyncComplete('직급', totalApiCalls, startTime); } export async function startKnoxTitleSyncScheduler() { if (DO_FIRST_RUN) { + if (!checkTimeRestriction()) { + return; + } + syncKnoxTitles().catch(console.error); } @@ -66,5 +99,5 @@ export async function startKnoxTitleSyncScheduler() { syncKnoxTitles().catch(console.error); }); - console.log(`[KNOX-SYNC] 직급 동기화 스케줄러 등록 (${CRON_STRING})`); + logSchedulerInfo('직급', CRON_STRING); }
\ No newline at end of file |
