summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-07-24 10:32:34 +0000
committerjoonhoekim <26rote@gmail.com>2025-07-24 10:32:34 +0000
commited0d6fcc98f671280c2ccde797b50693da88152e (patch)
tree6ea4bc8b13546fbd9de949a378fd2efb22c9cbdd
parenta50bc9baea332f996e6bc3a5d70c69f6d2d0f194 (diff)
(김준회) knox 임직원, 조직도, 직급 동기화 로직 수정 (delete & insert) 및 시간당 호출제한 반영, SSO 운영 변경 대응 (버튼 텍스트에 Stage 인 경우에만 Stage 표시), 부서 트리뷰에서 '하위' 뱃지 제거
-rw-r--r--.env.development8
-rw-r--r--.env.production13
-rw-r--r--app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-tree-view.tsx10
-rw-r--r--components/login/saml-login-button.tsx4
-rw-r--r--lib/knox-sync/common.ts162
-rw-r--r--lib/knox-sync/employee-sync-service.ts227
-rw-r--r--lib/knox-sync/organization-sync-service.ts167
-rw-r--r--lib/knox-sync/title-sync-service.ts87
8 files changed, 410 insertions, 268 deletions
diff --git a/.env.development b/.env.development
index 23a9cf1f..de57ef14 100644
--- a/.env.development
+++ b/.env.development
@@ -126,17 +126,19 @@ KNOX_SYSTEM_ID="KCD60REST00046"
KNOX_API_BEARER="3c7ac68c-b262-3f5b-a5c1-2c208add2964"
# 동기화 설정
KNOX_API_FORCE_LIMIT=false # 주간대량호출 강제 제한 여부
+KNOX_API_HOURLY_LIMIT=90 # 시간당 API 호출횟수 제한
+# KNOX_API_CALL_DELAY_MS # API 배치 처리간 딜레이 수동 설정이며, 자동 산출값보다 높아야 적용
KNOX_TITLE_SYNC_CRON="0 3 * * *" # 매일 새벽 3시
KNOX_TITLE_SYNC_FIRST_RUN=true
KNOX_ORGANIZATION_SYNC_CRON="30 3 * * *" # 매일 새벽 3시 30분
KNOX_ORGANIZATION_SYNC_FIRST_RUN=true
KNOX_EMPLOYEE_SYNC_CRON="0 4 * * *" # 매일 새벽 4시
-KNOX_EMPLOYEE_SYNC_FIRST_RUN=false # 양이 많으므로 시작시 갱신 X
+KNOX_EMPLOYEE_SYNC_FIRST_RUN=true # 양이 많으므로 시작시 갱신 X
# BASEURL: https://openapi.stage.samsung.net
-KNOX_API_BASE_URL="https://openapi.stage.samsung.net"
+KNOX_API_BASE_URL="https://openapi.samsung.net"
MESSENGER_ACCESS_TOKEN=""
MESSENGER_DEVICE_ID=""
-MESSENGER_BASE_URL="https://openapi.stage.samsung.net"
+MESSENGER_BASE_URL="https://openapi.samsung.net"
# 임시 환경변수 --- 요구사항 해소되면 삭제
READONLY_DB_URL="postgresql://readonly:tempReadOnly_123@localhost:5432/evcp"
diff --git a/.env.production b/.env.production
index ff4b58a2..2b3dadce 100644
--- a/.env.production
+++ b/.env.production
@@ -109,11 +109,6 @@ BIZPPURIO_FROM_NUMBER=05563092940
CRON_SECRET=dujin@DTS@2025dujin@DTS@2025dujin@DTS@2025dujin@DTS@2025dujin@DTS@2025dujin@DTS@2025
-
-S_GIPS_URL="http://qa.shi-api.com/evcp/Common/verifySgipsUser"
-S_GIPS_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJrZXlhdXRoLWV2Y3AiLCJuYmYiOjE3NDg2MTcyMDAsImV4cCI6MTc1NjYyNzIwMH0.RBI7mPXNel6T_c4qDiD4QALHnS6scZLZqQKiv4V_KAw"
-S_GIPS_RSA_KEY="MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtHC28Gw0U8taUwI8oJtG1H2JWGJtcsDw8w1oZbs759/Rag7zCF/bBilRtvlOz92wt02RCONetWK9VMgR2cqTJhfSaP92jIx0QQ+W1IrSKAiBxv+WtItsaWFLgYGIYNvrX8+qOnd+rDBvKDP9kk9Zqs1mHF2CbPRmao7/iEfhTb92hCgpFqsj/zU7nV3a8RbyifEMKSXTNanOEK2nTxAjld/csXQayHSaaqoH/lVySK0Qp6A2d2u2gEj/TAQ+Bhe7BsexNs2s5u5rykJqeROqJ7n0UsGgLd+uUDeo2nLqq5KeaXNcmACVcy2AASog78dzKwQmmGuC9Rp3zIoKOGdoQwIDAQAB"
-
# === SOAP 인터페이스 설정 ===
# MDG SOAP 인증 정보 (개발/품질/운영 비밀번호가 다름)
MDG_SOAP_USERNAME=P2038_01 # 개발/품질/운영 공통
@@ -130,17 +125,19 @@ KNOX_SYSTEM_ID="KCD60REST00046"
KNOX_API_BEARER="3c7ac68c-b262-3f5b-a5c1-2c208add2964"
# 동기화 설정
KNOX_API_FORCE_LIMIT=false # 주간대량호출 강제 제한 여부
+KNOX_API_HOURLY_LIMIT=90 # 시간당 API 호출횟수 제한
+# KNOX_API_CALL_DELAY_MS # API 배치 처리간 딜레이 수동 설정이며, 자동 산출값보다 높아야 적용
KNOX_TITLE_SYNC_CRON="0 3 * * *" # 매일 새벽 3시
KNOX_TITLE_SYNC_FIRST_RUN=true
KNOX_ORGANIZATION_SYNC_CRON="30 3 * * *" # 매일 새벽 3시 30분
KNOX_ORGANIZATION_SYNC_FIRST_RUN=true
KNOX_EMPLOYEE_SYNC_CRON="0 4 * * *" # 매일 새벽 4시
-KNOX_EMPLOYEE_SYNC_FIRST_RUN=false # 양이 많으므로 시작시 갱신 X
+KNOX_EMPLOYEE_SYNC_FIRST_RUN=true # 양이 많으므로 시작시 갱신 X
# BASEURL: https://openapi.stage.samsung.net
-KNOX_API_BASE_URL="https://openapi.stage.samsung.net"
+KNOX_API_BASE_URL="https://openapi.samsung.net"
MESSENGER_ACCESS_TOKEN=""
MESSENGER_DEVICE_ID=""
-MESSENGER_BASE_URL="https://openapi.stage.samsung.net"
+MESSENGER_BASE_URL="https://openapi.samsung.net"
# NAS 경로 설정
NAS_PATH="/evcp_nas"
diff --git a/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-tree-view.tsx b/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-tree-view.tsx
index 00c375a9..126f1eb7 100644
--- a/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-tree-view.tsx
+++ b/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-tree-view.tsx
@@ -141,16 +141,6 @@ function TreeNode({
{getDomainLabel(assignment.assignedDomain)}
</Badge>
)}
-
- {/* lowDepartmentYn 표시 */}
- {node.lowDepartmentYn === 'T' && (
- <Badge
- variant="secondary"
- className="text-xs shrink-0"
- >
- 하위
- </Badge>
- )}
</div>
{/* 부서 코드 */}
diff --git a/components/login/saml-login-button.tsx b/components/login/saml-login-button.tsx
index 02825e2f..b92015fd 100644
--- a/components/login/saml-login-button.tsx
+++ b/components/login/saml-login-button.tsx
@@ -20,6 +20,8 @@ interface SAMLLoginButtonProps {
size?: 'default' | 'sm' | 'lg' | 'icon'
}
+const isSsoStage = process.env.SAML_IDP_SSO_URL?.includes('stage');
+
export function SAMLLoginButton({
className,
children = "Knox SSO로 로그인하기",
@@ -115,7 +117,7 @@ export function KnoxSSOButton() {
className="w-full"
variant="outline"
>
- Knox SSO (STAGE 단계)
+ {isSsoStage ? 'Knox SSO (STAGE)' : 'Knox SSO'}
</SAMLLoginButton>
)
}
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