diff options
| author | joonhoekim <26rote@gmail.com> | 2025-07-23 05:55:31 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-07-23 05:55:31 +0000 |
| commit | edc0eabc8f5fc44408c28023ca155bd73ddf8183 (patch) | |
| tree | 8469a2d932008f2fb5aae9be854cba436b9f807b /lib | |
| parent | e1b1b57b6bfcd18ba4daa44230e8a915b4e93a15 (diff) | |
(김준회) Knox 임직원 기준으로 users 테이블 upsert 처리 (cron-job과 동기식 처리)
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/knox-sync/employee-sync-service.ts | 200 | ||||
| -rw-r--r-- | lib/knox-sync/organization-sync-service.ts | 144 |
2 files changed, 304 insertions, 40 deletions
diff --git a/lib/knox-sync/employee-sync-service.ts b/lib/knox-sync/employee-sync-service.ts index e9d422c7..282c493b 100644 --- a/lib/knox-sync/employee-sync-service.ts +++ b/lib/knox-sync/employee-sync-service.ts @@ -3,11 +3,13 @@ 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 } from 'drizzle-orm'; +import { sql, eq, and, ne } from 'drizzle-orm'; // 동기화 대상 회사 코드 (쉼표로 구분된 ENV) const COMPANIES = (process.env.KNOX_COMPANY_CODES || 'D60') @@ -27,6 +29,9 @@ const API_CALL_DELAY_MS = Math.max( 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)); @@ -156,6 +161,144 @@ async function syncEmployeesByCompany(companyCode: string): Promise<number> { return totalApiCalls; } +/** + * Knox Employee 데이터를 Users 테이블과 동기화 + * Knox 임직원 동기화 완료 후 실행되는 함수 + */ +async function syncEmployeesToUsers(): Promise<void> { + 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<string, string>(); + 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<void> { console.log('[KNOX-SYNC] Knox 임직원 동기화 시작'); console.log(`[KNOX-SYNC] 대상 회사: ${COMPANIES.join(', ')}`); @@ -163,30 +306,51 @@ export async function syncKnoxEmployees(): Promise<void> { let totalApiCalls = 0; const startTime = Date.now(); - // 각 회사별 순차 처리로 API 호출 제한 준수 - for (const companyCode of COMPANIES) { - const apiCalls = await syncEmployeesByCompany(companyCode); - totalApiCalls += apiCalls; + 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; + // 일일 호출 제한(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}초 소요`); + 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); } @@ -198,4 +362,6 @@ export async function startKnoxEmployeeSyncScheduler() { 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 ? '활성화' : '비활성화 (테스트 모드)'}`); } diff --git a/lib/knox-sync/organization-sync-service.ts b/lib/knox-sync/organization-sync-service.ts index 0b77174b..8ce9c0ca 100644 --- a/lib/knox-sync/organization-sync-service.ts +++ b/lib/knox-sync/organization-sync-service.ts @@ -10,7 +10,7 @@ import { import { sql } from 'drizzle-orm'; // 동기화 대상 회사 코드 (쉼표로 구분된 ENV) -const COMPANIES = (process.env.KNOX_COMPANY_CODES || 'P2') +const COMPANIES = (process.env.KNOX_COMPANY_CODES || 'D60') .split(',') .map((c) => c.trim()) .filter(Boolean); @@ -21,9 +21,26 @@ 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, @@ -86,47 +103,128 @@ async function upsertOrganizations(orgs: Organization[]) { 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<number> { + 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<void> { - console.log('[KNOX-SYNC] 조직 동기화 시작'); + console.log('[KNOX-SYNC] Knox 조직 동기화 시작'); + console.log(`[KNOX-SYNC] 대상 회사: ${COMPANIES.join(', ')}`); + + let totalApiCalls = 0; + const startTime = Date.now(); + // 각 회사별 순차 처리로 API 호출 제한 준수 for (const companyCode of COMPANIES) { - try { - let page = 1; - let totalPage = 1; - do { - const resp = await searchOrganizations({ companyCode, page: String(page) }); - - if (resp.result === 'success') { - await upsertOrganizations(resp.organizations); - totalPage = resp.totalPage; - console.log( - `[KNOX-SYNC] 조직 동기화 ${companyCode} ${page}/${totalPage} 페이지 처리` - ); - } else { - console.warn(`[KNOX-SYNC] 조직 동기화 실패: ${companyCode} page ${page}`); - break; - } + const apiCalls = await syncOrganizationsByCompany(companyCode); + totalApiCalls += apiCalls; - page += 1; - } while (page <= totalPage); - } catch (err) { - console.error(`[KNOX-SYNC] 조직 동기화 오류 (company ${companyCode})`, err); + // 일일 호출 제한(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; } } - console.log('[KNOX-SYNC] 조직 동기화 완료'); + 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 ? '활성화' : '비활성화 (테스트 모드)'}`); }
\ No newline at end of file |
