From 37cac7208569351706886df6f81e40d273198c1e Mon Sep 17 00:00:00 2001 From: chpark Date: Mon, 20 Apr 2026 12:14:50 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20Pipeline=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D=20=ED=86=B5=EC=9D=BC=20=EB=B0=8F=20AI=20=EC=97=90?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=ED=8A=B8/=EC=9E=A5=EB=B9=84=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docker/K8s 배포 설정을 pipeline-backend/pipeline-front로 통일 - 네임스페이스, 서비스, PVC 등 k8s 리소스명 pipeline-* 로 변경 - AI 에이전트 관리 기능 추가 (에이전트, 그룹, 프로바이더, 대화, API 키, 지식베이스) - 장비 연결 관리 기능 추가 (PLC/Modbus/OPC-UA/MQTT) - 배치 스케줄러에 AI agent/device collection/crawling 타입 추가 - 배치 편집 UI 개선 (6가지 실행 방식 지원) - 회사별 페이지(COMPANY_*) 제거 및 AdminPageRenderer 최적화 - 메뉴 재구성: 장비 연결 관리 시스템관리로 이동, 에이전트 오케스트레이션으로 개명 - ai-assistant 디렉토리 제거 (backend-node로 통합) Co-Authored-By: Claude Opus 4.7 (1M context) --- MULTI_AGENT_ARCHITECTURE.md | 373 ++ OPENCLAW_INTEGRATION.md | 181 + ai-assistant/.env.example | 25 - ai-assistant/Dockerfile.win | 17 - ai-assistant/README.md | 43 - ai-assistant/package-lock.json | 3455 ----------------- ai-assistant/package.json | 38 - ai-assistant/src/app.js | 186 - .../src/controllers/admin.controller.js | 474 --- .../src/controllers/api-key.controller.js | 215 - .../src/controllers/auth.controller.js | 195 - .../src/controllers/chat.controller.js | 152 - .../src/controllers/model.controller.js | 67 - .../src/controllers/usage.controller.js | 177 - .../src/controllers/user.controller.js | 113 - .../src/middlewares/auth.middleware.js | 257 -- .../middlewares/error-handler.middleware.js | 80 - .../middlewares/usage-logger.middleware.js | 50 - .../src/middlewares/validation.middleware.js | 30 - ai-assistant/src/models/api-key.model.js | 130 - ai-assistant/src/models/index.js | 55 - ai-assistant/src/models/llm-provider.model.js | 143 - ai-assistant/src/models/usage-log.model.js | 164 - ai-assistant/src/models/user.model.js | 92 - ai-assistant/src/routes/admin.routes.js | 151 - ai-assistant/src/routes/api-key.routes.js | 99 - ai-assistant/src/routes/auth.routes.js | 76 - ai-assistant/src/routes/chat.routes.js | 55 - ai-assistant/src/routes/index.js | 45 - ai-assistant/src/routes/model.routes.js | 24 - ai-assistant/src/routes/usage.routes.js | 81 - ai-assistant/src/routes/user.routes.js | 50 - ai-assistant/src/seeders/001-llm-providers.js | 74 - ai-assistant/src/services/init.service.js | 128 - ai-assistant/src/services/llm.service.js | 385 -- ai-assistant/src/swagger/api-docs.js | 359 -- backend-node/src/app.ts | 35 +- .../src/controllers/aiAgentController.ts | 138 + .../controllers/batchManagementController.ts | 20 + backend-node/src/database/runMigration.ts | 88 + .../src/middleware/aiApiKeyAuthMiddleware.ts | 57 + backend-node/src/routes/aiAgentGroupRoutes.ts | 57 + backend-node/src/routes/aiAgentRoutes.ts | 44 + backend-node/src/routes/aiScheduleRoutes.ts | 28 + .../src/routes/batchManagementRoutes.ts | 6 + backend-node/src/routes/knowledgeRoutes.ts | 110 + .../src/routes/openClawProxyRoutes.ts | 205 + .../routes/pipelineDeviceConnectionRoutes.ts | 147 + .../src/services/aiAgentApiKeyService.ts | 89 + .../services/aiAgentConversationService.ts | 87 + .../src/services/aiAgentGroupService.ts | 185 + .../src/services/aiAgentProviderService.ts | 95 + backend-node/src/services/aiAgentService.ts | 101 + .../src/services/aiAgentUsageService.ts | 88 + .../src/services/aiAnalysisLogService.ts | 72 + .../src/services/aiSchedulerService.ts | 185 + .../src/services/batchSchedulerService.ts | 142 +- .../collector/deviceCollectorService.ts | 324 ++ .../collector/protocols/modbusClient.ts | 283 ++ .../services/collector/protocols/xgtClient.ts | 392 ++ backend-node/src/services/llmClient.ts | 390 ++ .../src/services/multiAgentExecutionEngine.ts | 478 +++ .../src/services/openClawSyncService.ts | 141 + .../pipelineDeviceConnectionService.ts | 280 ++ backend-node/src/types/aiAgent.ts | 153 + backend-node/src/types/pipelineDeviceTypes.ts | 105 + backend-node/src/utils/startOpenClaw.ts | 107 + deploy/onpremise/docker-compose.yml | 4 +- docker-compose.backend.win.yml | 8 +- docker-compose.frontend.win.yml | 8 +- docker/deploy/docker-compose.yml | 12 +- docker/dev/docker-compose.backend.mac.yml | 21 +- docker/dev/docker-compose.frontend.mac.yml | 20 +- docker/prod/docker-compose.backend.prod.yml | 8 +- docker/prod/docker-compose.frontend.prod.yml | 4 +- frontend/app/(auth)/login/page.tsx | 8 +- .../design/change-management/page.tsx | 1524 -------- .../COMPANY_10/design/design-request/page.tsx | 781 ---- .../(main)/COMPANY_10/design/my-work/page.tsx | 1958 ---------- .../(main)/COMPANY_10/design/project/page.tsx | 1501 ------- .../design/task-management/page.tsx | 1288 ------ .../(main)/COMPANY_10/equipment/info/page.tsx | 950 ----- .../equipment/inspection-record/page.tsx | 351 -- .../equipment/plc-settings/page.tsx | 501 --- .../logistics/inbound-outbound/page.tsx | 424 -- .../(main)/COMPANY_10/logistics/info/page.tsx | 904 ----- .../COMPANY_10/logistics/inventory/page.tsx | 761 ---- .../logistics/material-status/page.tsx | 661 ---- .../COMPANY_10/logistics/outbound/page.tsx | 1594 -------- .../COMPANY_10/logistics/packaging/page.tsx | 1033 ----- .../COMPANY_10/logistics/receiving/page.tsx | 1660 -------- .../COMPANY_10/logistics/warehouse/page.tsx | 1608 -------- .../COMPANY_10/master-data/company/page.tsx | 759 ---- .../master-data/department/page.tsx | 772 ---- .../COMPANY_10/master-data/item-info/page.tsx | 902 ----- .../COMPANY_10/master-data/options/page.tsx | 136 - .../app/(main)/COMPANY_10/mold/info/page.tsx | 1417 ------- .../COMPANY_10/monitoring/equipment/page.tsx | 666 ---- .../COMPANY_10/monitoring/production/page.tsx | 557 --- .../COMPANY_10/monitoring/quality/page.tsx | 426 -- .../outsourcing/subcontractor-item/page.tsx | 582 --- .../outsourcing/subcontractor/page.tsx | 1417 ------- .../(main)/COMPANY_10/production/bom/page.tsx | 2552 ------------ .../production/plan-management/page.tsx | 1853 --------- .../process-info/ItemRoutingTab.tsx | 1034 ----- .../process-info/ProcessMasterTab.tsx | 664 ---- .../process-info/ProcessWorkStandardTab.tsx | 17 - .../production/process-info/page.tsx | 223 -- .../COMPANY_10/production/result/page.tsx | 641 --- .../WorkStandardEditModal.tsx | 1007 ----- .../production/work-instruction/page.tsx | 850 ---- .../(main)/COMPANY_10/purchase/order/page.tsx | 1329 ------- .../purchase/purchase-item/page.tsx | 2035 ---------- .../COMPANY_10/purchase/supplier/page.tsx | 2800 ------------- .../quality/inspection-result/page.tsx | 351 -- .../COMPANY_10/quality/inspection/page.tsx | 1698 -------- .../quality/item-inspection/page.tsx | 740 ---- .../(main)/COMPANY_10/sales/claim/page.tsx | 932 ----- .../(main)/COMPANY_10/sales/customer/page.tsx | 2789 ------------- .../(main)/COMPANY_10/sales/order/page.tsx | 1634 -------- .../(main)/COMPANY_10/sales/quote/page.tsx | 995 ----- .../COMPANY_10/sales/sales-item/page.tsx | 2016 ---------- .../COMPANY_10/sales/shipping-order/page.tsx | 928 ----- .../COMPANY_10/sales/shipping-plan/page.tsx | 454 --- .../design/change-management/page.tsx | 1524 -------- .../COMPANY_16/design/design-request/page.tsx | 781 ---- .../(main)/COMPANY_16/design/my-work/page.tsx | 1958 ---------- .../(main)/COMPANY_16/design/project/page.tsx | 1501 ------- .../design/task-management/page.tsx | 1288 ------ .../(main)/COMPANY_16/equipment/info/page.tsx | 950 ----- .../equipment/inspection-record/page.tsx | 351 -- .../equipment/plc-settings/page.tsx | 501 --- .../logistics/inbound-outbound/page.tsx | 424 -- .../(main)/COMPANY_16/logistics/info/page.tsx | 904 ----- .../COMPANY_16/logistics/inventory/page.tsx | 761 ---- .../logistics/material-status/page.tsx | 661 ---- .../COMPANY_16/logistics/outbound/page.tsx | 1594 -------- .../COMPANY_16/logistics/packaging/page.tsx | 1033 ----- .../COMPANY_16/logistics/receiving/page.tsx | 1660 -------- .../COMPANY_16/logistics/warehouse/page.tsx | 1608 -------- .../COMPANY_16/master-data/company/page.tsx | 759 ---- .../master-data/department/page.tsx | 772 ---- .../COMPANY_16/master-data/item-info/page.tsx | 902 ----- .../COMPANY_16/master-data/options/page.tsx | 136 - .../app/(main)/COMPANY_16/mold/info/page.tsx | 1417 ------- .../COMPANY_16/monitoring/equipment/page.tsx | 666 ---- .../COMPANY_16/monitoring/production/page.tsx | 557 --- .../COMPANY_16/monitoring/quality/page.tsx | 426 -- .../outsourcing/subcontractor-item/page.tsx | 582 --- .../outsourcing/subcontractor/page.tsx | 1417 ------- .../(main)/COMPANY_16/production/bom/page.tsx | 2554 ------------ .../production/plan-management/page.tsx | 1853 --------- .../process-info/ItemRoutingTab.tsx | 1034 ----- .../process-info/ProcessMasterTab.tsx | 664 ---- .../process-info/ProcessWorkStandardTab.tsx | 17 - .../production/process-info/page.tsx | 223 -- .../COMPANY_16/production/result/page.tsx | 641 --- .../WorkStandardEditModal.tsx | 1007 ----- .../production/work-instruction/page.tsx | 850 ---- .../(main)/COMPANY_16/purchase/order/page.tsx | 1329 ------- .../purchase/purchase-item/page.tsx | 2035 ---------- .../COMPANY_16/purchase/supplier/page.tsx | 2800 ------------- .../quality/inspection-result/page.tsx | 351 -- .../COMPANY_16/quality/inspection/page.tsx | 1698 -------- .../quality/item-inspection/page.tsx | 740 ---- .../(main)/COMPANY_16/sales/claim/page.tsx | 932 ----- .../(main)/COMPANY_16/sales/customer/page.tsx | 2789 ------------- .../(main)/COMPANY_16/sales/order/page.tsx | 1634 -------- .../(main)/COMPANY_16/sales/quote/page.tsx | 995 ----- .../COMPANY_16/sales/sales-item/page.tsx | 2016 ---------- .../COMPANY_16/sales/shipping-order/page.tsx | 928 ----- .../COMPANY_16/sales/shipping-plan/page.tsx | 454 --- .../design/change-management/page.tsx | 1524 -------- .../COMPANY_29/design/design-request/page.tsx | 781 ---- .../(main)/COMPANY_29/design/my-work/page.tsx | 1958 ---------- .../(main)/COMPANY_29/design/project/page.tsx | 1501 ------- .../design/task-management/page.tsx | 1288 ------ .../(main)/COMPANY_29/equipment/info/page.tsx | 950 ----- .../equipment/inspection-record/page.tsx | 351 -- .../equipment/plc-settings/page.tsx | 501 --- .../logistics/inbound-outbound/page.tsx | 424 -- .../(main)/COMPANY_29/logistics/info/page.tsx | 904 ----- .../COMPANY_29/logistics/inventory/page.tsx | 761 ---- .../logistics/material-status/page.tsx | 661 ---- .../COMPANY_29/logistics/outbound/page.tsx | 1594 -------- .../COMPANY_29/logistics/packaging/page.tsx | 1033 ----- .../COMPANY_29/logistics/receiving/page.tsx | 1660 -------- .../COMPANY_29/logistics/warehouse/page.tsx | 1608 -------- .../COMPANY_29/master-data/company/page.tsx | 759 ---- .../master-data/department/page.tsx | 772 ---- .../COMPANY_29/master-data/item-info/page.tsx | 902 ----- .../COMPANY_29/master-data/options/page.tsx | 136 - .../app/(main)/COMPANY_29/mold/info/page.tsx | 1417 ------- .../COMPANY_29/monitoring/equipment/page.tsx | 666 ---- .../COMPANY_29/monitoring/production/page.tsx | 557 --- .../COMPANY_29/monitoring/quality/page.tsx | 426 -- .../outsourcing/subcontractor-item/page.tsx | 582 --- .../outsourcing/subcontractor/page.tsx | 1417 ------- .../(main)/COMPANY_29/production/bom/page.tsx | 2552 ------------ .../production/plan-management/page.tsx | 1853 --------- .../process-info/ItemRoutingTab.tsx | 1034 ----- .../process-info/ProcessMasterTab.tsx | 664 ---- .../process-info/ProcessWorkStandardTab.tsx | 17 - .../production/process-info/page.tsx | 223 -- .../COMPANY_29/production/result/page.tsx | 641 --- .../WorkStandardEditModal.tsx | 1007 ----- .../production/work-instruction/page.tsx | 850 ---- .../(main)/COMPANY_29/purchase/order/page.tsx | 1329 ------- .../purchase/purchase-item/page.tsx | 2035 ---------- .../COMPANY_29/purchase/supplier/page.tsx | 2800 ------------- .../quality/inspection-result/page.tsx | 351 -- .../COMPANY_29/quality/inspection/page.tsx | 1698 -------- .../quality/item-inspection/page.tsx | 740 ---- .../(main)/COMPANY_29/sales/claim/page.tsx | 932 ----- .../(main)/COMPANY_29/sales/customer/page.tsx | 2789 ------------- .../(main)/COMPANY_29/sales/order/page.tsx | 1634 -------- .../(main)/COMPANY_29/sales/quote/page.tsx | 995 ----- .../COMPANY_29/sales/sales-item/page.tsx | 2016 ---------- .../COMPANY_29/sales/shipping-order/page.tsx | 928 ----- .../COMPANY_29/sales/shipping-plan/page.tsx | 454 --- .../design/change-management/page.tsx | 1524 -------- .../COMPANY_30/design/design-request/page.tsx | 781 ---- .../(main)/COMPANY_30/design/my-work/page.tsx | 1958 ---------- .../(main)/COMPANY_30/design/project/page.tsx | 1501 ------- .../design/task-management/page.tsx | 1288 ------ .../(main)/COMPANY_30/equipment/info/page.tsx | 950 ----- .../equipment/inspection-record/page.tsx | 351 -- .../equipment/plc-settings/page.tsx | 501 --- .../logistics/inbound-outbound/page.tsx | 424 -- .../(main)/COMPANY_30/logistics/info/page.tsx | 904 ----- .../COMPANY_30/logistics/inventory/page.tsx | 761 ---- .../logistics/material-status/page.tsx | 661 ---- .../COMPANY_30/logistics/outbound/page.tsx | 1594 -------- .../COMPANY_30/logistics/packaging/page.tsx | 1033 ----- .../COMPANY_30/logistics/receiving/page.tsx | 1660 -------- .../COMPANY_30/logistics/warehouse/page.tsx | 1608 -------- .../COMPANY_30/master-data/company/page.tsx | 759 ---- .../master-data/department/page.tsx | 772 ---- .../COMPANY_30/master-data/item-info/page.tsx | 902 ----- .../COMPANY_30/master-data/options/page.tsx | 136 - .../app/(main)/COMPANY_30/mold/info/page.tsx | 1417 ------- .../COMPANY_30/monitoring/equipment/page.tsx | 666 ---- .../COMPANY_30/monitoring/production/page.tsx | 557 --- .../COMPANY_30/monitoring/quality/page.tsx | 426 -- .../outsourcing/subcontractor-item/page.tsx | 582 --- .../outsourcing/subcontractor/page.tsx | 1417 ------- .../(main)/COMPANY_30/production/bom/page.tsx | 2552 ------------ .../production/plan-management/page.tsx | 1853 --------- .../process-info/ItemRoutingTab.tsx | 1034 ----- .../process-info/ProcessMasterTab.tsx | 664 ---- .../process-info/ProcessWorkStandardTab.tsx | 17 - .../production/process-info/page.tsx | 223 -- .../COMPANY_30/production/result/page.tsx | 641 --- .../WorkStandardEditModal.tsx | 1007 ----- .../production/work-instruction/page.tsx | 850 ---- .../(main)/COMPANY_30/purchase/order/page.tsx | 1329 ------- .../purchase/purchase-item/page.tsx | 2059 ---------- .../COMPANY_30/purchase/supplier/page.tsx | 2800 ------------- .../quality/inspection-result/page.tsx | 351 -- .../COMPANY_30/quality/inspection/page.tsx | 1698 -------- .../quality/item-inspection/page.tsx | 740 ---- .../(main)/COMPANY_30/sales/claim/page.tsx | 932 ----- .../(main)/COMPANY_30/sales/customer/page.tsx | 2789 ------------- .../(main)/COMPANY_30/sales/order/page.tsx | 1634 -------- .../(main)/COMPANY_30/sales/quote/page.tsx | 995 ----- .../COMPANY_30/sales/sales-item/page.tsx | 2016 ---------- .../COMPANY_30/sales/shipping-order/page.tsx | 928 ----- .../COMPANY_30/sales/shipping-plan/page.tsx | 454 --- .../design/change-management/page.tsx | 1524 -------- .../COMPANY_7/design/design-request/page.tsx | 781 ---- .../(main)/COMPANY_7/design/my-work/page.tsx | 1958 ---------- .../(main)/COMPANY_7/design/project/page.tsx | 1501 ------- .../COMPANY_7/design/task-management/page.tsx | 1288 ------ .../(main)/COMPANY_7/equipment/info/page.tsx | 950 ----- .../equipment/inspection-record/page.tsx | 351 -- .../COMPANY_7/equipment/plc-settings/page.tsx | 501 --- .../logistics/inbound-outbound/page.tsx | 424 -- .../(main)/COMPANY_7/logistics/info/page.tsx | 904 ----- .../COMPANY_7/logistics/inventory/page.tsx | 761 ---- .../logistics/material-status/page.tsx | 661 ---- .../COMPANY_7/logistics/outbound/page.tsx | 1594 -------- .../COMPANY_7/logistics/packaging/page.tsx | 1033 ----- .../COMPANY_7/logistics/receiving/page.tsx | 1660 -------- .../COMPANY_7/logistics/warehouse/page.tsx | 1608 -------- .../COMPANY_7/master-data/company/page.tsx | 759 ---- .../COMPANY_7/master-data/department/page.tsx | 772 ---- .../COMPANY_7/master-data/item-info/page.tsx | 902 ----- .../COMPANY_7/master-data/options/page.tsx | 136 - .../app/(main)/COMPANY_7/mold/info/page.tsx | 1417 ------- .../COMPANY_7/monitoring/equipment/page.tsx | 666 ---- .../COMPANY_7/monitoring/production/page.tsx | 557 --- .../COMPANY_7/monitoring/quality/page.tsx | 426 -- .../outsourcing/subcontractor-item/page.tsx | 582 --- .../outsourcing/subcontractor/page.tsx | 1418 ------- .../(main)/COMPANY_7/production/bom/page.tsx | 2514 ------------ .../production/plan-management/page.tsx | 1853 --------- .../process-info/ItemRoutingTab.tsx | 1034 ----- .../process-info/ProcessMasterTab.tsx | 664 ---- .../process-info/ProcessWorkStandardTab.tsx | 17 - .../production/process-info/page.tsx | 223 -- .../COMPANY_7/production/result/page.tsx | 641 --- .../WorkStandardEditModal.tsx | 1007 ----- .../production/work-instruction/page.tsx | 850 ---- .../(main)/COMPANY_7/purchase/order/page.tsx | 1329 ------- .../COMPANY_7/purchase/purchase-item/page.tsx | 2035 ---------- .../COMPANY_7/purchase/supplier/page.tsx | 2800 ------------- .../quality/inspection-result/page.tsx | 351 -- .../COMPANY_7/quality/inspection/page.tsx | 1698 -------- .../quality/item-inspection/page.tsx | 740 ---- .../app/(main)/COMPANY_7/sales/claim/page.tsx | 932 ----- .../(main)/COMPANY_7/sales/customer/page.tsx | 2789 ------------- .../app/(main)/COMPANY_7/sales/order/page.tsx | 1634 -------- .../app/(main)/COMPANY_7/sales/quote/page.tsx | 995 ----- .../COMPANY_7/sales/sales-item/page.tsx | 2016 ---------- .../COMPANY_7/sales/shipping-order/page.tsx | 928 ----- .../COMPANY_7/sales/shipping-plan/page.tsx | 454 --- .../design/change-management/page.tsx | 1524 -------- .../COMPANY_8/design/design-request/page.tsx | 781 ---- .../(main)/COMPANY_8/design/my-work/page.tsx | 1958 ---------- .../(main)/COMPANY_8/design/project/page.tsx | 1501 ------- .../COMPANY_8/design/task-management/page.tsx | 1288 ------ .../(main)/COMPANY_8/equipment/info/page.tsx | 950 ----- .../equipment/inspection-record/page.tsx | 351 -- .../COMPANY_8/equipment/plc-settings/page.tsx | 501 --- .../logistics/inbound-outbound/page.tsx | 424 -- .../(main)/COMPANY_8/logistics/info/page.tsx | 904 ----- .../COMPANY_8/logistics/inventory/page.tsx | 761 ---- .../logistics/material-status/page.tsx | 661 ---- .../COMPANY_8/logistics/outbound/page.tsx | 1594 -------- .../COMPANY_8/logistics/packaging/page.tsx | 1033 ----- .../COMPANY_8/logistics/receiving/page.tsx | 1660 -------- .../COMPANY_8/logistics/warehouse/page.tsx | 1608 -------- .../COMPANY_8/master-data/company/page.tsx | 759 ---- .../COMPANY_8/master-data/department/page.tsx | 772 ---- .../COMPANY_8/master-data/item-info/page.tsx | 902 ----- .../COMPANY_8/master-data/options/page.tsx | 136 - .../app/(main)/COMPANY_8/mold/info/page.tsx | 1417 ------- .../COMPANY_8/monitoring/equipment/page.tsx | 666 ---- .../COMPANY_8/monitoring/production/page.tsx | 557 --- .../COMPANY_8/monitoring/quality/page.tsx | 426 -- .../outsourcing/subcontractor-item/page.tsx | 582 --- .../outsourcing/subcontractor/page.tsx | 1418 ------- .../(main)/COMPANY_8/production/bom/page.tsx | 2551 ------------ .../production/plan-management/page.tsx | 1853 --------- .../process-info/ItemRoutingTab.tsx | 1034 ----- .../process-info/ProcessMasterTab.tsx | 664 ---- .../process-info/ProcessWorkStandardTab.tsx | 17 - .../production/process-info/page.tsx | 223 -- .../COMPANY_8/production/result/page.tsx | 641 --- .../WorkStandardEditModal.tsx | 1007 ----- .../production/work-instruction/page.tsx | 850 ---- .../(main)/COMPANY_8/purchase/order/page.tsx | 1329 ------- .../COMPANY_8/purchase/purchase-item/page.tsx | 2035 ---------- .../COMPANY_8/purchase/supplier/page.tsx | 2800 ------------- .../quality/inspection-result/page.tsx | 351 -- .../COMPANY_8/quality/inspection/page.tsx | 1698 -------- .../quality/item-inspection/page.tsx | 740 ---- .../app/(main)/COMPANY_8/sales/claim/page.tsx | 932 ----- .../(main)/COMPANY_8/sales/customer/page.tsx | 2789 ------------- .../app/(main)/COMPANY_8/sales/order/page.tsx | 1634 -------- .../app/(main)/COMPANY_8/sales/quote/page.tsx | 995 ----- .../COMPANY_8/sales/sales-item/page.tsx | 2016 ---------- .../COMPANY_8/sales/shipping-order/page.tsx | 928 ----- .../COMPANY_8/sales/shipping-plan/page.tsx | 454 --- .../design/change-management/page.tsx | 1524 -------- .../COMPANY_9/design/design-request/page.tsx | 781 ---- .../(main)/COMPANY_9/design/my-work/page.tsx | 1958 ---------- .../(main)/COMPANY_9/design/project/page.tsx | 1501 ------- .../COMPANY_9/design/task-management/page.tsx | 1288 ------ .../(main)/COMPANY_9/equipment/info/page.tsx | 950 ----- .../equipment/inspection-record/page.tsx | 351 -- .../COMPANY_9/equipment/plc-settings/page.tsx | 501 --- .../logistics/inbound-outbound/page.tsx | 424 -- .../(main)/COMPANY_9/logistics/info/page.tsx | 904 ----- .../COMPANY_9/logistics/inventory/page.tsx | 761 ---- .../logistics/material-status/page.tsx | 661 ---- .../COMPANY_9/logistics/outbound/page.tsx | 1594 -------- .../COMPANY_9/logistics/packaging/page.tsx | 1033 ----- .../COMPANY_9/logistics/receiving/page.tsx | 1660 -------- .../COMPANY_9/logistics/warehouse/page.tsx | 1608 -------- .../COMPANY_9/master-data/company/page.tsx | 759 ---- .../COMPANY_9/master-data/department/page.tsx | 772 ---- .../COMPANY_9/master-data/item-info/page.tsx | 902 ----- .../COMPANY_9/master-data/options/page.tsx | 136 - .../app/(main)/COMPANY_9/mold/info/page.tsx | 1417 ------- .../COMPANY_9/monitoring/equipment/page.tsx | 666 ---- .../COMPANY_9/monitoring/production/page.tsx | 557 --- .../COMPANY_9/monitoring/quality/page.tsx | 426 -- .../outsourcing/subcontractor-item/page.tsx | 582 --- .../outsourcing/subcontractor/page.tsx | 1417 ------- .../(main)/COMPANY_9/production/bom/page.tsx | 2552 ------------ .../production/plan-management/page.tsx | 1853 --------- .../process-info/ItemRoutingTab.tsx | 1034 ----- .../process-info/ProcessMasterTab.tsx | 664 ---- .../process-info/ProcessWorkStandardTab.tsx | 17 - .../production/process-info/page.tsx | 223 -- .../COMPANY_9/production/result/page.tsx | 641 --- .../WorkStandardEditModal.tsx | 1007 ----- .../production/work-instruction/page.tsx | 850 ---- .../(main)/COMPANY_9/purchase/order/page.tsx | 1329 ------- .../COMPANY_9/purchase/purchase-item/page.tsx | 2059 ---------- .../COMPANY_9/purchase/supplier/page.tsx | 2800 ------------- .../quality/inspection-result/page.tsx | 351 -- .../COMPANY_9/quality/inspection/page.tsx | 1698 -------- .../quality/item-inspection/page.tsx | 740 ---- .../app/(main)/COMPANY_9/sales/claim/page.tsx | 932 ----- .../(main)/COMPANY_9/sales/customer/page.tsx | 2789 ------------- .../app/(main)/COMPANY_9/sales/order/page.tsx | 1634 -------- .../app/(main)/COMPANY_9/sales/quote/page.tsx | 995 ----- .../COMPANY_9/sales/sales-item/page.tsx | 2016 ---------- .../COMPANY_9/sales/shipping-order/page.tsx | 928 ----- .../COMPANY_9/sales/shipping-plan/page.tsx | 454 --- .../(main)/admin/aiAssistant/agents/page.tsx | 425 ++ .../aiAssistant/api-keys-manage/page.tsx | 488 +++ .../admin/aiAssistant/api-keys/page.tsx | 299 -- .../admin/aiAssistant/api-test/page.tsx | 180 - .../(main)/admin/aiAssistant/chat/page.tsx | 142 - .../admin/aiAssistant/conversations/page.tsx | 104 + .../admin/aiAssistant/dashboard/page.tsx | 190 - .../(main)/admin/aiAssistant/history/page.tsx | 157 - .../admin/aiAssistant/knowledge/page.tsx | 279 ++ .../app/(main)/admin/aiAssistant/layout.tsx | 123 +- .../app/(main)/admin/aiAssistant/page.tsx | 4 +- .../admin/aiAssistant/providers/page.tsx | 394 ++ .../(main)/admin/aiAssistant/usage/page.tsx | 195 - .../admin/aiAssistant/workspace/page.tsx | 734 ++++ .../automaticMng/batchmngList/create/page.tsx | 2154 +++++++--- .../batchmngList/edit/[id]/page.tsx | 53 +- .../admin/automaticMng/batchmngList/page.tsx | 296 +- .../admin/automaticMng/exconList/page.tsx | 434 +-- .../admin/batch-management-new/page.tsx | 1782 --------- .../(main)/admin/batch-management/page.tsx | 525 --- frontend/app/(main)/admin/page.tsx | 4 +- .../app/(main)/admin/pipeline-device/page.tsx | 227 ++ .../barcodeList/designer/[labelId]/page.tsx | 30 - .../admin/screenMng/barcodeList/page.tsx | 143 - .../admin/screenMng/dashboardList/page.tsx | 306 +- .../admin/screenMng/popScreenMngList/page.tsx | 448 --- .../admin/screenMng/popSettingsMng/page.tsx | 1053 ----- .../admin/screenMng/screenMngList/page.tsx | 455 --- frontend/app/(pop)/layout.tsx | 4 +- frontend/app/globals.css | 80 + frontend/app/layout.tsx | 10 +- .../admin/RestApiConnectionList.tsx | 387 +- .../admin/RestApiConnectionModal.tsx | 9 - frontend/components/auth/LoginFooter.tsx | 3 +- frontend/components/auth/LoginForm.tsx | 142 +- frontend/components/auth/LoginHeader.tsx | 31 +- .../dashboard/widgets/MapTestWidgetV2.tsx | 24 +- frontend/components/dataflow/DataFlowList.tsx | 469 +-- .../dataflow/node-editor/CommandPalette.tsx | 28 +- .../dataflow/node-editor/FlowBreadcrumb.tsx | 10 +- .../dataflow/node-editor/FlowEditor.tsx | 10 +- .../dataflow/node-editor/FlowToolbar.tsx | 55 +- .../dataflow/node-editor/NodeContextMenu.tsx | 6 +- .../dataflow/node-editor/SlideOverSheet.tsx | 20 +- .../node-editor/nodes/CompactNodeShell.tsx | 22 +- .../components/layout/AdminPageRenderer.tsx | 700 +--- frontend/components/layout/AppLayout.tsx | 344 +- frontend/components/layout/Logo.tsx | 34 +- frontend/components/layout/TabBar.tsx | 15 +- frontend/components/layout/ThemeToggle.tsx | 84 +- .../components/providers/ThemeProvider.tsx | 100 +- frontend/constants/auth.ts | 6 +- frontend/lib/api/aiAgent.ts | 81 + frontend/lib/api/aiAssistant/client.ts | 127 - frontend/lib/api/aiAssistant/index.ts | 8 - frontend/lib/api/aiAssistant/types.ts | 70 - frontend/lib/api/batch.ts | 61 +- frontend/lib/api/pipelineDevice.ts | 33 + frontend/next.config.mjs | 3 + frontend/providers/QueryProvider.tsx | 3 - frontend/public/favicon.svg | 17 + k8s/namespace.yaml | 9 +- ....yaml => pipeline-backend-deployment.yaml} | 41 +- ...xplor-config.yaml => pipeline-config.yaml} | 11 +- ...nt.yaml => pipeline-front-deployment.yaml} | 29 +- ...lor-ingress.yaml => pipeline-ingress.yaml} | 23 +- ...template => pipeline-secret.yaml.template} | 13 +- 479 files changed, 11173 insertions(+), 385275 deletions(-) create mode 100644 MULTI_AGENT_ARCHITECTURE.md create mode 100644 OPENCLAW_INTEGRATION.md delete mode 100644 ai-assistant/.env.example delete mode 100644 ai-assistant/Dockerfile.win delete mode 100644 ai-assistant/README.md delete mode 100644 ai-assistant/package-lock.json delete mode 100644 ai-assistant/package.json delete mode 100644 ai-assistant/src/app.js delete mode 100644 ai-assistant/src/controllers/admin.controller.js delete mode 100644 ai-assistant/src/controllers/api-key.controller.js delete mode 100644 ai-assistant/src/controllers/auth.controller.js delete mode 100644 ai-assistant/src/controllers/chat.controller.js delete mode 100644 ai-assistant/src/controllers/model.controller.js delete mode 100644 ai-assistant/src/controllers/usage.controller.js delete mode 100644 ai-assistant/src/controllers/user.controller.js delete mode 100644 ai-assistant/src/middlewares/auth.middleware.js delete mode 100644 ai-assistant/src/middlewares/error-handler.middleware.js delete mode 100644 ai-assistant/src/middlewares/usage-logger.middleware.js delete mode 100644 ai-assistant/src/middlewares/validation.middleware.js delete mode 100644 ai-assistant/src/models/api-key.model.js delete mode 100644 ai-assistant/src/models/index.js delete mode 100644 ai-assistant/src/models/llm-provider.model.js delete mode 100644 ai-assistant/src/models/usage-log.model.js delete mode 100644 ai-assistant/src/models/user.model.js delete mode 100644 ai-assistant/src/routes/admin.routes.js delete mode 100644 ai-assistant/src/routes/api-key.routes.js delete mode 100644 ai-assistant/src/routes/auth.routes.js delete mode 100644 ai-assistant/src/routes/chat.routes.js delete mode 100644 ai-assistant/src/routes/index.js delete mode 100644 ai-assistant/src/routes/model.routes.js delete mode 100644 ai-assistant/src/routes/usage.routes.js delete mode 100644 ai-assistant/src/routes/user.routes.js delete mode 100644 ai-assistant/src/seeders/001-llm-providers.js delete mode 100644 ai-assistant/src/services/init.service.js delete mode 100644 ai-assistant/src/services/llm.service.js delete mode 100644 ai-assistant/src/swagger/api-docs.js create mode 100644 backend-node/src/controllers/aiAgentController.ts create mode 100644 backend-node/src/middleware/aiApiKeyAuthMiddleware.ts create mode 100644 backend-node/src/routes/aiAgentGroupRoutes.ts create mode 100644 backend-node/src/routes/aiAgentRoutes.ts create mode 100644 backend-node/src/routes/aiScheduleRoutes.ts create mode 100644 backend-node/src/routes/knowledgeRoutes.ts create mode 100644 backend-node/src/routes/openClawProxyRoutes.ts create mode 100644 backend-node/src/routes/pipelineDeviceConnectionRoutes.ts create mode 100644 backend-node/src/services/aiAgentApiKeyService.ts create mode 100644 backend-node/src/services/aiAgentConversationService.ts create mode 100644 backend-node/src/services/aiAgentGroupService.ts create mode 100644 backend-node/src/services/aiAgentProviderService.ts create mode 100644 backend-node/src/services/aiAgentService.ts create mode 100644 backend-node/src/services/aiAgentUsageService.ts create mode 100644 backend-node/src/services/aiAnalysisLogService.ts create mode 100644 backend-node/src/services/aiSchedulerService.ts create mode 100644 backend-node/src/services/collector/deviceCollectorService.ts create mode 100644 backend-node/src/services/collector/protocols/modbusClient.ts create mode 100644 backend-node/src/services/collector/protocols/xgtClient.ts create mode 100644 backend-node/src/services/llmClient.ts create mode 100644 backend-node/src/services/multiAgentExecutionEngine.ts create mode 100644 backend-node/src/services/openClawSyncService.ts create mode 100644 backend-node/src/services/pipelineDeviceConnectionService.ts create mode 100644 backend-node/src/types/aiAgent.ts create mode 100644 backend-node/src/types/pipelineDeviceTypes.ts create mode 100644 backend-node/src/utils/startOpenClaw.ts delete mode 100644 frontend/app/(main)/COMPANY_10/design/change-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/design/design-request/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/design/my-work/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/design/project/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/design/task-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/equipment/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/equipment/inspection-record/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/equipment/plc-settings/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/logistics/inbound-outbound/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/logistics/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/logistics/material-status/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/logistics/outbound/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/logistics/warehouse/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/master-data/company/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/master-data/department/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/master-data/item-info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/master-data/options/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/mold/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/monitoring/equipment/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/monitoring/production/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/monitoring/quality/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/outsourcing/subcontractor-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/outsourcing/subcontractor/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/production/bom/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/production/process-info/ProcessMasterTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/production/process-info/ProcessWorkStandardTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/production/process-info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/production/result/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/purchase/order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/purchase/supplier/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/quality/inspection-result/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/sales/claim/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/sales/customer/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/sales/order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/sales/quote/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/sales/sales-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/sales/shipping-order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_10/sales/shipping-plan/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/design/change-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/design/design-request/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/design/my-work/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/design/project/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/design/task-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/equipment/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/equipment/inspection-record/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/equipment/plc-settings/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/logistics/inbound-outbound/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/logistics/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/logistics/material-status/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/logistics/packaging/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/logistics/warehouse/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/master-data/company/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/master-data/department/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/master-data/options/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/mold/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/monitoring/equipment/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/monitoring/production/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/monitoring/quality/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/outsourcing/subcontractor-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/outsourcing/subcontractor/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/production/bom/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/production/process-info/ItemRoutingTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/production/process-info/ProcessMasterTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/production/process-info/ProcessWorkStandardTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/production/process-info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/production/result/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/purchase/order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/purchase/supplier/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/quality/inspection-result/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/sales/claim/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/sales/customer/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/sales/order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/sales/quote/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/sales/shipping-order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_16/sales/shipping-plan/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/design/change-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/design/design-request/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/design/my-work/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/design/project/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/design/task-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/equipment/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/equipment/inspection-record/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/equipment/plc-settings/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/logistics/inbound-outbound/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/logistics/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/logistics/inventory/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/logistics/material-status/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/logistics/outbound/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/logistics/packaging/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/logistics/receiving/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/logistics/warehouse/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/master-data/company/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/master-data/department/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/master-data/options/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/mold/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/monitoring/equipment/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/monitoring/production/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/monitoring/quality/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/outsourcing/subcontractor-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/outsourcing/subcontractor/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/production/bom/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/production/process-info/ItemRoutingTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/production/process-info/ProcessMasterTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/production/process-info/ProcessWorkStandardTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/production/process-info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/production/result/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/production/work-instruction/WorkStandardEditModal.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/purchase/order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/purchase/purchase-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/purchase/supplier/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/quality/inspection-result/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/quality/inspection/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/sales/claim/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/sales/customer/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/sales/order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/sales/quote/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/sales/sales-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/sales/shipping-order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_29/sales/shipping-plan/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/design/change-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/design/design-request/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/design/my-work/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/design/project/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/design/task-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/equipment/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/equipment/inspection-record/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/equipment/plc-settings/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/logistics/inbound-outbound/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/logistics/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/logistics/material-status/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/logistics/outbound/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/logistics/packaging/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/logistics/receiving/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/logistics/warehouse/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/master-data/company/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/master-data/department/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/master-data/options/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/mold/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/monitoring/equipment/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/monitoring/production/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/monitoring/quality/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/outsourcing/subcontractor-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/outsourcing/subcontractor/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/production/bom/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/production/plan-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/production/process-info/ItemRoutingTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/production/process-info/ProcessMasterTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/production/process-info/ProcessWorkStandardTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/production/process-info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/production/result/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/production/work-instruction/WorkStandardEditModal.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/production/work-instruction/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/purchase/order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/purchase/purchase-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/purchase/supplier/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/quality/inspection-result/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/quality/inspection/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/sales/claim/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/sales/customer/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/sales/order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/sales/quote/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/sales/sales-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/sales/shipping-order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_30/sales/shipping-plan/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/design/change-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/design/design-request/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/design/my-work/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/design/project/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/design/task-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/equipment/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/equipment/inspection-record/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/equipment/plc-settings/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/logistics/inbound-outbound/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/logistics/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/logistics/inventory/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/logistics/material-status/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/logistics/outbound/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/logistics/packaging/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/logistics/warehouse/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/master-data/company/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/master-data/department/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/master-data/options/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/mold/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/monitoring/equipment/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/monitoring/production/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/monitoring/quality/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/outsourcing/subcontractor/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/production/bom/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/production/process-info/ProcessMasterTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/production/process-info/ProcessWorkStandardTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/production/process-info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/production/result/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/purchase/order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/purchase/purchase-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/purchase/supplier/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/quality/inspection-result/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/quality/inspection/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/quality/item-inspection/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/sales/claim/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/sales/customer/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/sales/order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/sales/quote/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_7/sales/shipping-plan/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/design/change-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/design/design-request/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/design/my-work/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/design/project/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/design/task-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/equipment/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/equipment/inspection-record/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/equipment/plc-settings/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/logistics/inbound-outbound/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/logistics/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/logistics/inventory/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/logistics/material-status/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/logistics/outbound/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/logistics/packaging/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/logistics/receiving/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/logistics/warehouse/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/master-data/company/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/master-data/department/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/master-data/item-info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/master-data/options/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/mold/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/monitoring/equipment/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/monitoring/production/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/monitoring/quality/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/outsourcing/subcontractor-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/outsourcing/subcontractor/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/production/bom/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/production/plan-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/production/process-info/ItemRoutingTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/production/process-info/ProcessMasterTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/production/process-info/ProcessWorkStandardTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/production/process-info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/production/result/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/production/work-instruction/WorkStandardEditModal.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/production/work-instruction/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/purchase/order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/purchase/purchase-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/purchase/supplier/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/quality/inspection-result/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/quality/inspection/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/sales/claim/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/sales/customer/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/sales/order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/sales/quote/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/sales/sales-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/sales/shipping-order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_8/sales/shipping-plan/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/design/change-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/design/design-request/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/design/my-work/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/design/project/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/design/task-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/equipment/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/equipment/inspection-record/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/equipment/plc-settings/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/logistics/inbound-outbound/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/logistics/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/logistics/inventory/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/logistics/material-status/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/logistics/outbound/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/logistics/packaging/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/logistics/receiving/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/logistics/warehouse/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/master-data/company/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/master-data/department/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/master-data/item-info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/master-data/options/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/mold/info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/monitoring/equipment/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/monitoring/production/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/monitoring/quality/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/outsourcing/subcontractor-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/outsourcing/subcontractor/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/production/bom/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/production/plan-management/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/production/process-info/ItemRoutingTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/production/process-info/ProcessMasterTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/production/process-info/ProcessWorkStandardTab.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/production/process-info/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/production/result/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/production/work-instruction/WorkStandardEditModal.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/purchase/order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/purchase/purchase-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/purchase/supplier/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/quality/inspection-result/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/quality/inspection/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/sales/claim/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/sales/customer/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/sales/order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/sales/quote/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/sales/sales-item/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/sales/shipping-order/page.tsx delete mode 100644 frontend/app/(main)/COMPANY_9/sales/shipping-plan/page.tsx create mode 100644 frontend/app/(main)/admin/aiAssistant/agents/page.tsx create mode 100644 frontend/app/(main)/admin/aiAssistant/api-keys-manage/page.tsx delete mode 100644 frontend/app/(main)/admin/aiAssistant/api-keys/page.tsx delete mode 100644 frontend/app/(main)/admin/aiAssistant/api-test/page.tsx delete mode 100644 frontend/app/(main)/admin/aiAssistant/chat/page.tsx create mode 100644 frontend/app/(main)/admin/aiAssistant/conversations/page.tsx delete mode 100644 frontend/app/(main)/admin/aiAssistant/dashboard/page.tsx delete mode 100644 frontend/app/(main)/admin/aiAssistant/history/page.tsx create mode 100644 frontend/app/(main)/admin/aiAssistant/knowledge/page.tsx create mode 100644 frontend/app/(main)/admin/aiAssistant/providers/page.tsx delete mode 100644 frontend/app/(main)/admin/aiAssistant/usage/page.tsx create mode 100644 frontend/app/(main)/admin/aiAssistant/workspace/page.tsx delete mode 100644 frontend/app/(main)/admin/batch-management-new/page.tsx delete mode 100644 frontend/app/(main)/admin/batch-management/page.tsx create mode 100644 frontend/app/(main)/admin/pipeline-device/page.tsx delete mode 100644 frontend/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page.tsx delete mode 100644 frontend/app/(main)/admin/screenMng/barcodeList/page.tsx delete mode 100644 frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx delete mode 100644 frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx delete mode 100644 frontend/app/(main)/admin/screenMng/screenMngList/page.tsx create mode 100644 frontend/lib/api/aiAgent.ts delete mode 100644 frontend/lib/api/aiAssistant/client.ts delete mode 100644 frontend/lib/api/aiAssistant/index.ts delete mode 100644 frontend/lib/api/aiAssistant/types.ts create mode 100644 frontend/lib/api/pipelineDevice.ts create mode 100644 frontend/public/favicon.svg rename k8s/{vexplor-backend-deployment.yaml => pipeline-backend-deployment.yaml} (77%) rename k8s/{vexplor-config.yaml => pipeline-config.yaml} (78%) rename k8s/{vexplor-frontend-deployment.yaml => pipeline-front-deployment.yaml} (79%) rename k8s/{vexplor-ingress.yaml => pipeline-ingress.yaml} (83%) rename k8s/{vexplor-secret.yaml.template => pipeline-secret.yaml.template} (83%) diff --git a/MULTI_AGENT_ARCHITECTURE.md b/MULTI_AGENT_ARCHITECTURE.md new file mode 100644 index 00000000..883fcf55 --- /dev/null +++ b/MULTI_AGENT_ARCHITECTURE.md @@ -0,0 +1,373 @@ +# PIPELINE 멀티 에이전트 아키텍처 설계서 + +## 1. 개요 + +PIPELINE은 OpenClaw 기반 멀티 에이전트 AI 플랫폼을 빌트인으로 탑재하여, +여러 AI 에이전트가 기존 시스템의 데이터를 활용하여 협업하고 행동하는 구조를 제공합니다. + +### 설계 원칙 + +``` +에이전트 = 템플릿 (재사용 가능) +커넥터 = 데이터 소스 (동적 연결) +그룹 = 에이전트 + 커넥터 조립 (멀티 에이전트 단위) +``` + +- 에이전트는 **역할(시스템 프롬프트)**만 정의하고, 데이터 접근은 커넥터가 담당 +- 같은 에이전트가 **다른 커넥터를 물고 다른 회사/시스템에서 동작** 가능 +- 외부 시스템은 **멀티 에이전트 그룹 1개만 호출**하면 내부에서 자동 분업 + +--- + +## 2. 전체 아키텍처 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ PIPELINE 플랫폼 │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 멀티 에이전트 워크스페이스 │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────┐ │ │ +│ │ │ [MES 멀티 에이전트] │ │ │ +│ │ │ │ │ │ +│ │ │ ① 영업담당 에이전트 │ │ │ +│ │ │ └─ 커넥터: A회사 수주DB │ │ │ +│ │ │ ↓ │ │ │ +│ │ │ ② 설계담당 에이전트 │ │ │ +│ │ │ └─ 커넥터: CAD REST API + BOM 파일 │ │ │ +│ │ │ ↓ │ │ │ +│ │ │ ③ 구매담당 에이전트 │ │ │ +│ │ │ └─ 커넥터: 자재DB + 원자재 시세 크롤러 │ │ │ +│ │ └──────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────┐ │ │ +│ │ │ [SCM 멀티 에이전트] │ │ │ +│ │ │ │ │ │ +│ │ │ ① 물류담당 에이전트 │ │ │ +│ │ │ └─ 커넥터: B회사 재고DB + PLC 설비데이터 │ │ │ +│ │ │ ↓ │ │ │ +│ │ │ ② 품질담당 에이전트 │ │ │ +│ │ │ └─ 커넥터: 검사성적서 파일 + 불량DB │ │ │ +│ │ └──────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────┐ ┌──────────────────────────────┐ │ +│ │ 에이전트 관리 │ │ 데이터 커넥터 허브 │ │ +│ │ (템플릿) │ │ ┌──────┐ ┌────────┐ │ │ +│ │ - 영업담당 │ │ │ DB │ │REST API│ │ │ +│ │ - 설계담당 │ │ └──────┘ └────────┘ │ │ +│ │ - 구매담당 │ │ ┌──────┐ ┌────────┐ ┌────┐│ │ +│ │ - 물류담당 │ │ │ PLC │ │ File │ │크롤││ │ +│ │ - 품질담당 │ │ └──────┘ └────────┘ └────┘│ │ +│ └─────────────────┘ └──────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ OpenClaw Gateway (내장) │ │ +│ │ - MCP 도구 동적 주입 │ │ +│ │ - LLM 프로바이더 (Claude / GPT / Gemini / Ollama) │ │ +│ └──────────────────────────────────────────────────────┘ │ +└────────────────────┬────────────────────────────────────────┘ + │ API 키 (sk-pipe-xxxxx) + │ + ┌────────────────▼────────────────────┐ + │ 외부 시스템 │ + │ MES / ERP / WMS / SCM / 모바일 앱 │ + └─────────────────────────────────────┘ +``` + +--- + +## 3. 핵심 개념 + +### 3-1. 에이전트 (Agent) = 재사용 템플릿 + +``` +에이전트: "영업담당" +├── 모델: Claude Sonnet 4 +├── 시스템 프롬프트: "당신은 영업 데이터 분석 전문가입니다..." +└── 커넥터: 없음 (그룹에서 동적 할당) + +이 에이전트는 여러 그룹에서 재사용: + - A회사 MES 그룹 → 커넥터: A회사 수주DB + - B회사 MES 그룹 → 커넥터: B회사 수주DB + - C회사 SCM 그룹 → 커넥터: C회사 거래처DB +``` + +### 3-2. 데이터 커넥터 허브 (Data Connector Hub) + +| 커넥터 유형 | 데이터 소스 | 에이전트 활용 | +|------------|-----------|-------------| +| **Database** | PostgreSQL, MSSQL, MariaDB, Oracle | SQL 쿼리로 데이터 조회/분석 | +| **REST API** | 외부 시스템 API 호출 | JSON 데이터 수집/명령 전달 | +| **PLC** | 장비 데이터 (Modbus, OPC-UA) | 실시간 설비 상태 모니터링 | +| **File** | CSV, Excel, JSON, PDF | 문서 분석, 데이터 추출 | +| **Crawler** | 웹 크롤링 (시세, 뉴스, 규격) | 외부 정보 수집 | + +### 3-3. 멀티 에이전트 그룹 = 에이전트 + 커넥터 조립 + +``` +[A회사 MES 멀티 에이전트] + ├── ① 영업담당 + A회사 수주DB ← 같은 에이전트 + ├── ② 구매담당 + A회사 자재DB + 시세 크롤러 + └── ③ 품질담당 + A회사 검사DB + +[B회사 MES 멀티 에이전트] + ├── ① 영업담당 + B회사 수주DB ← 같은 에이전트, 다른 커넥터 + ├── ② 구매담당 + B회사 자재DB + └── ③ 설계담당 + B회사 CAD API + BOM 파일 +``` + +**에이전트 5개 + 그룹 2개 = 6개 조합** (에이전트 따로 만들면 6개 필요) + +--- + +## 4. 실행 흐름 + +### 4-1. 외부 시스템 호출 시 + +``` +POST /api/ai/v1/groups/a-company-mes +{ + "message": "이번 달 납기 지연 가능한 건 알려줘" +} + +┌─ 실행 파이프라인 ──────────────────────────────────────┐ +│ │ +│ ① 영업담당 에이전트 실행 │ +│ MCP 도구: A회사 수주DB 쿼리 실행 │ +│ → "수주 10건 중 3건 납기 위험 (A-2026-001, ...)" │ +│ │ │ +│ ▼ │ +│ ② 설계담당 에이전트 실행 │ +│ MCP 도구: CAD API 호출 + BOM 파일 분석 │ +│ → "A-2026-001: BOM 분석 결과 부품 2개 미확보" │ +│ │ │ +│ ▼ │ +│ ③ 구매담당 에이전트 실행 │ +│ MCP 도구: 자재DB 조회 + 원자재 시세 크롤링 │ +│ → "부품A: 납품 5일 소요, 긴급 발주 추천" │ +│ → "부품B: 재고 있음 (창고 3번)" │ +│ │ │ +│ ▼ │ +│ 최종 응답 취합 │ +│ → 납기 위험 3건, 부품 미확보 2건, 긴급 발주 1건 추천 │ +│ │ +└────────────────────────────────────────────────────────┘ +``` + +### 4-2. 스케줄 실행 (예정) + +``` +[매일 오전 9시 자동 실행] + → SCM 멀티 에이전트 실행 + → 결과를 이메일/슬랙/대시보드에 전송 +``` + +--- + +## 5. DB 스키마 + +### 기존 테이블 (활용) + +| 테이블 | 용도 | +|--------|------| +| `external_db_connections` | DB 커넥션 정보 (호스트, 포트, 계정) | +| `external_rest_api_connections` | REST API 커넥션 정보 (URL, 인증) | + +### 새로 추가한 테이블 + +```sql +-- AI 에이전트 (템플릿) +ai_agents + ├── id, agent_id, name, model, system_prompt, status + +-- LLM 프로바이더 (API 키) +ai_llm_providers + ├── id, name(anthropic/openai/google), api_key_encrypted, model_name + +-- 멀티 에이전트 그룹 +ai_agent_groups + ├── id, group_id, name, description, status + +-- 그룹 멤버 (에이전트 + 커넥터 매핑) +ai_agent_group_members + ├── group_id → ai_agent_groups + ├── agent_id → ai_agents + ├── role_name (이 그룹에서의 역할) + ├── connectors (JSONB: 동적 커넥터 목록) + │ [ + │ {"type":"database", "connection_id":3, "name":"A회사 수주DB"}, + │ {"type":"rest_api", "connection_id":5, "name":"CAD API"}, + │ {"type":"crawler", "config_id":2, "name":"원자재 시세"}, + │ {"type":"file", "path":"/uploads/bom.csv", "name":"BOM 파일"}, + │ {"type":"plc", "connection_id":1, "name":"1호기 PLC"} + │ ] + └── execution_order (실행 순서) + +-- API 키 (외부 서비스용) +ai_agent_api_keys + ├── key_hash, user_id, rate_limit, monthly_token_limit + +-- 사용량 로그 +ai_agent_usage_logs + ├── tokens, cost, response_time +``` + +--- + +## 6. API 엔드포인트 + +### 관리 API (Pipeline JWT 인증) + +| Method | Path | 설명 | +|--------|------|------| +| GET/POST | `/api/ai-agents` | 에이전트 CRUD | +| GET/POST | `/api/ai-agents/providers/*` | LLM 프로바이더 관리 | +| GET/POST | `/api/ai-agents/keys/*` | API 키 발급/관리 | +| GET/POST | `/api/ai-agent-groups` | 멀티 에이전트 그룹 CRUD | +| POST | `/api/ai-agent-groups/:id/members` | 그룹에 에이전트+커넥터 추가 | +| GET | `/api/ai-agent-groups/connectors` | 사용 가능한 커넥터 목록 | + +### 외부 서비스 API (API Key 인증) + +| Method | Path | 설명 | +|--------|------|------| +| POST | `/api/ai/v1/chat/completions` | 단일 에이전트 채팅 | +| POST | `/api/ai/v1/groups/:groupId` | 멀티 에이전트 실행 (예정) | +| GET | `/api/ai/v1/models` | 사용 가능 모델 목록 | + +--- + +## 7. 관리 화면 (프론트엔드) + +| 메뉴 | 경로 | 기능 | +|------|------|------| +| 멀티 에이전트 워크스페이스 | `/admin/aiAssistant/workspace` | 그룹 생성, 에이전트+커넥터 조립 | +| 에이전트 관리 | `/admin/aiAssistant/agents` | 에이전트 템플릿 CRUD | +| LLM 프로바이더 | `/admin/aiAssistant/providers` | Claude/GPT/Gemini API 키 등록 | +| API 키 발급 | `/admin/aiAssistant/api-keys-manage` | 외부 서비스용 키 생성 | +| 대화 모니터링 | `/admin/aiAssistant/conversations` | 에이전트 대화 내역 열람 | + +--- + +## 8. 예측 진화 시스템 + +### 이력 누적 → 자동 학습 구조 + +``` +에이전트는 매번 실행할 때마다: +1. 현재 데이터 조회 (MCP 커넥터) +2. 과거 분석 이력 조회 (ai_analysis_logs) +3. 과거 예측 vs 실제 비교 (accuracy_score) +4. 이 모든 컨텍스트로 분석/예측 수행 +5. 결과를 다시 이력에 저장 + +→ 실행할수록 데이터가 쌓이고, 에이전트가 참고할 이력이 늘어남 +→ 별도 학습 없이 LLM의 추론 능력으로 패턴 인식/예측 가능 +``` + +### ai_analysis_logs 테이블 + +```sql +ai_analysis_logs +├── group_id → 어떤 그룹이 실행했는지 +├── input_message → 질문 +├── analysis_result → 분석 결과 (전체 텍스트) +├── prediction → 예측 내용 (JSONB) +├── actual_result → 실제 결과 (나중에 입력) +├── accuracy_score → 예측 정확도 (0~100) +├── tokens_used, duration_ms +└── created_at → 시계열 이력 +``` + +### 예시: 30일 후 예측 정확도 변화 + +``` +1일차: 이력 없음 → 예측 정확도: N/A +7일차: 이력 6건 참고 → 예측 정확도: 60% +14일차: 이력 13건 참고 → 예측 정확도: 72% +30일차: 이력 29건 참고 → 패턴 인식 → 예측 정확도: 85% +``` + +--- + +## 9. 스케줄러 & 알림 + +### 정기 실행 구조 + +``` +[ai_agent_schedules] +├── cron_expression: "0 9 * * *" (매일 오전 9시) +├── group_id → MES 멀티 에이전트 +├── input_message: "오늘 납기 지연 위험이 있는 건 알려줘" +├── notification: +│ ├── system_notice: true (시스템 공지) +│ ├── webhook: "https://hooks.slack.com/..." (슬랙) +│ └── email: ["buyer@company.com"] (이메일) +└── is_active: true +``` + +### 알림 흐름 + +``` +[cron 실행] → [멀티 에이전트 실행] → [결과 분석] + │ + ┌─────────────────────────┤ + ▼ ▼ ▼ + [시스템 공지 저장] [슬랙 웹훅 발송] [이메일 발송] + system_notice 테이블 POST webhook URL SMTP 발송 +``` + +--- + +## 10. 커넥터 유형 (구현 완료) + +| 커넥터 | 구현 방식 | 에이전트 활용 | +|--------|----------|-------------| +| **Database** | 기존 external_db_connections 활용 | SQL 쿼리로 데이터 조회 | +| **REST API** | 기존 external_rest_api_connections 활용 | JSON 데이터 수집 | +| **File** | 파일 시스템 직접 읽기 (CSV/JSON) | 문서 분석, 데이터 추출 | +| **Crawler** | 기존 crawl_configs 활용 | 웹 크롤링 데이터 수집 | +| **PLC** | AAS 디지털 트윈 REST API → DB 저장 | 설비 상태 조회/분석 | + +### PLC 데이터 흐름 + +``` +[PLC 장비] → [AAS 디지털 트윈] → REST API + ↓ + [Pipeline DB 저장] + (plc_device_data 등) + ↓ + [에이전트가 DB 조회] + "1호기 온도 추세 분석" +``` + +- AAS에서 장비 종류, 벤더 등 **마스터 데이터** 관리 +- Pipeline에서 실제 **PLC 통신 스크립트** 구현 (Modbus TCP/OPC-UA) +- 수집 데이터를 DB에 저장 → 에이전트가 이력 분석/예측 + +--- + +## 11. 확장 계획 + +| 기능 | 설명 | 우선순위 | +|------|------|---------| +| 워크플로우 편집기 | 드래그앤드롭으로 파이프라인 구성 | 중 | +| PLC 통신 모듈 | Modbus TCP/OPC-UA 직접 통신 | 중 | +| 실시간 모니터링 | WebSocket으로 에이전트 실행 실시간 스트리밍 | 중 | +| A/B 테스트 | 다른 모델/프롬프트로 비교 실험 | 하 | +| 에이전트 마켓플레이스 | 템플릿 공유/배포 | 하 | + +--- + +## 9. 기술 스택 + +| 구성요소 | 기술 | +|---------|------| +| 멀티 에이전트 엔진 | OpenClaw (Node.js, MCP 지원) | +| 백엔드 | Express.js + TypeScript | +| 프론트엔드 | Next.js 15 + shadcn/ui | +| 데이터베이스 | PostgreSQL | +| LLM | Claude, GPT, Gemini, DeepSeek, Ollama | +| 커넥터 | 기존 external_db/rest_api + PLC/File/Crawler 확장 | diff --git a/OPENCLAW_INTEGRATION.md b/OPENCLAW_INTEGRATION.md new file mode 100644 index 00000000..591a7c5d --- /dev/null +++ b/OPENCLAW_INTEGRATION.md @@ -0,0 +1,181 @@ +# OpenClaw 멀티 에이전트 AI 통합 + +## 개요 + +Pipeline 프로젝트에 OpenClaw 멀티 에이전트 AI 플랫폼을 빌트인으로 통합했습니다. +Pipeline UI에서 에이전트 생성/관리, API 키 발급, 대화 모니터링, 사용량 추적이 가능하며, +외부 서비스에서 Pipeline이 발급한 API 키로 에이전트를 사용할 수 있습니다. + +## 아키텍처 + +``` +외부 서비스 (ERP, MES, 물류 등) + │ POST /api/ai/v1/chat/completions + │ Authorization: Bearer sk-pipe-xxxxx + ▼ +Pipeline Backend (:8080) + ├── 인증 (JWT 또는 API Key) + ├── Rate Limit 체크 + ├── 프록시 → OpenClaw Gateway (:18789) + ├── 응답 로깅 (토큰, 비용) + └── DB 저장 (PostgreSQL) + ▼ +OpenClaw Gateway (내장 프로세스) + ├── Agent A (데이터 분석) + ├── Agent B (보고서 작성) + ├── Agent C (모니터링) + └── LLM: Claude / GPT / Ollama +``` + +## DB 테이블 + +| 테이블 | 용도 | +|--------|------| +| `ai_agents` | 에이전트 정의 (이름, 모델, 시스템프롬프트, 도구, 상태) | +| `ai_agent_api_keys` | 외부 서비스용 API 키 (해시 저장, 사용량 제한) | +| `ai_agent_conversations` | 대화 세션 | +| `ai_agent_messages` | 대화 메시지 | +| `ai_agent_usage_logs` | 토큰 사용량, 비용, 응답시간 로그 | +| `ai_llm_providers` | LLM API 키 관리 (암호화 저장) | + +## API 엔드포인트 + +### 에이전트 관리 (Pipeline JWT 인증) + +``` +GET /api/ai-agents 에이전트 목록 +GET /api/ai-agents/:id 에이전트 상세 +POST /api/ai-agents 에이전트 생성 +PUT /api/ai-agents/:id 에이전트 수정 +DELETE /api/ai-agents/:id 에이전트 삭제 +``` + +### API 키 관리 (Pipeline JWT 인증) + +``` +GET /api/ai-agents/keys/list 키 목록 +POST /api/ai-agents/keys 키 발급 (sk-pipe-xxx 형태, 한 번만 노출) +DELETE /api/ai-agents/keys/:id 키 폐기 +``` + +### LLM 프로바이더 관리 (Pipeline JWT 인증) + +``` +GET /api/ai-agents/providers/list 프로바이더 목록 +POST /api/ai-agents/providers 프로바이더 추가 (API 키 암호화 저장) +PUT /api/ai-agents/providers/:id 프로바이더 수정 +DELETE /api/ai-agents/providers/:id 프로바이더 삭제 +``` + +### 대화 모니터링 (Pipeline JWT 인증) + +``` +GET /api/ai-agents/conversations/list 대화 목록 (페이징) +GET /api/ai-agents/conversations/:id 대화 + 메시지 상세 +``` + +### 사용량 (Pipeline JWT 인증) + +``` +GET /api/ai-agents/usage/summary 요약 (오늘/이번달 토큰, 비용) +GET /api/ai-agents/usage/logs 상세 로그 (페이징) +GET /api/ai-agents/usage/daily 일별 차트 데이터 +``` + +### 외부 서비스용 프록시 (API Key 인증) + +``` +POST /api/ai/v1/chat/completions OpenAI 호환 채팅 (스트리밍 지원) +GET /api/ai/v1/models 사용 가능 모델 목록 +GET /api/ai/v1/health Gateway 상태 +``` + +## 외부 서비스에서 사용하기 + +### 1. API 키 발급 + +Pipeline 관리자 화면 > AI 에이전트 > API 키 관리에서 키를 발급합니다. +`sk-pipe-xxxxxxxx` 형태의 키가 생성되며, 최초 1회만 노출됩니다. + +### 2. 채팅 API 호출 + +```bash +curl -X POST https://pipeline.example.com/api/ai/v1/chat/completions \ + -H "Authorization: Bearer sk-pipe-xxxxx" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": "매출 데이터를 분석해줘"} + ] + }' +``` + +### 3. 응답 형태 (OpenAI 호환) + +```json +{ + "id": "chatcmpl-xxx", + "object": "chat.completion", + "model": "claude-sonnet-4-20250514", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": "매출 데이터 분석 결과..." }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 15, + "completion_tokens": 200, + "total_tokens": 215 + } +} +``` + +## 프론트엔드 관리 화면 + +| 경로 | 기능 | +|------|------| +| `/admin/aiAssistant/agents` | 에이전트 CRUD (카드형 목록) | +| `/admin/aiAssistant/providers` | LLM API 키 등록/관리 | +| `/admin/aiAssistant/conversations` | 대화 모니터링 (메시지 열람) | +| `/admin/aiAssistant/api-keys` | 외부 서비스용 API 키 발급 | +| `/admin/aiAssistant/usage` | 사용량 대시보드 | + +## 환경 변수 + +```env +# OpenClaw Gateway 포트 (기본: 18789) +OPENCLAW_GATEWAY_PORT=18789 + +# OpenClaw 활성화 여부 (기본: true) +OPENCLAW_ENABLED=true +``` + +## 파일 구조 + +``` +backend-node/src/ +├── controllers/aiAgentController.ts # 에이전트/키/프로바이더/대화/사용량 컨트롤러 +├── services/ +│ ├── aiAgentService.ts # 에이전트 CRUD +│ ├── aiAgentApiKeyService.ts # API 키 발급/검증 +│ ├── aiAgentProviderService.ts # LLM 프로바이더 관리 +│ ├── aiAgentConversationService.ts # 대화 로깅 +│ └── aiAgentUsageService.ts # 사용량 추적 +├── routes/ +│ ├── aiAgentRoutes.ts # 관리 API 라우트 +│ └── openClawProxyRoutes.ts # 외부 서비스 프록시 +├── middleware/ +│ └── aiApiKeyAuthMiddleware.ts # API 키 인증 +├── utils/startOpenClaw.ts # Gateway 프로세스 관리 +├── types/aiAgent.ts # TypeScript 타입 +└── db/migrations/ + └── 300_create_openclaw_tables.sql # 마이그레이션 + +frontend/ +├── lib/api/aiAgent.ts # API 클라이언트 +└── app/(main)/admin/aiAssistant/ + ├── agents/page.tsx # 에이전트 관리 + ├── providers/page.tsx # 프로바이더 관리 + └── conversations/page.tsx # 대화 모니터링 +``` diff --git a/ai-assistant/.env.example b/ai-assistant/.env.example deleted file mode 100644 index 3ecc1ba0..00000000 --- a/ai-assistant/.env.example +++ /dev/null @@ -1,25 +0,0 @@ -# AI Assistant API (VEXPLOR 내장) - 환경 변수 -# 이 파일을 .env 로 복사한 뒤 값 설정 - -NODE_ENV=development -PORT=3100 - -# PostgreSQL (AI 어시스턴트 전용 DB) -DB_HOST=localhost -DB_PORT=5432 -DB_USER=ai_assistant -DB_PASSWORD=ai_assistant_password -DB_NAME=ai_assistant_db - -# JWT -JWT_SECRET=your-super-secret-jwt-key-change-in-production -JWT_EXPIRES_IN=7d -JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production -JWT_REFRESH_EXPIRES_IN=30d - -# LLM (구글 키 등) -GEMINI_API_KEY=your-gemini-api-key -GEMINI_MODEL=gemini-2.0-flash - -RATE_LIMIT_WINDOW_MS=60000 -RATE_LIMIT_MAX_REQUESTS=100 diff --git a/ai-assistant/Dockerfile.win b/ai-assistant/Dockerfile.win deleted file mode 100644 index f652b312..00000000 --- a/ai-assistant/Dockerfile.win +++ /dev/null @@ -1,17 +0,0 @@ -# AI 어시스턴트 API - Docker (Windows 개발용) -FROM node:20-bookworm-slim - -RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -COPY package*.json ./ -RUN npm ci --omit=dev - -COPY . . - -ENV NODE_ENV=development -EXPOSE 3100 - -CMD ["node", "src/app.js"] diff --git a/ai-assistant/README.md b/ai-assistant/README.md deleted file mode 100644 index 8eb5c2ec..00000000 --- a/ai-assistant/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# AI 어시스턴트 API (VEXPLOR 내장) - -VEXPLOR와 **같은 서비스**로 동작하도록 이 API는 포트 3100에서 구동되고, backend-node가 `/api/ai/v1` 요청을 여기로 프록시합니다. - -## 동작 방식 - -- **프론트(9771)** → `/api/ai/v1/*` 호출 -- **Next.js** → `8080/api/ai/v1/*` 로 rewrite -- **backend-node(8080)** → `3100/api/v1/*` 로 프록시 → **이 서비스** - -따라서 사용자는 **다른 포트를 쓰지 않고** VEXPLOR만 켜도 AI 기능을 사용할 수 있습니다. - -## 서비스 올리는 순서 (한 번에 동작하게) - -1. **AI 어시스턴트 API (이 폴더, 포트 3100)** - ```bash - cd ai-assistant - npm install - cp .env.example .env # 필요 시 DB, JWT, GEMINI_API_KEY 등 수정 - npm start - ``` - -2. **backend-node (포트 8080)** - ```bash - cd backend-node - npm run dev - ``` - -3. **프론트 (포트 9771)** - ```bash - cd frontend - npm run dev - ``` - -브라우저에서는 `http://localhost:9771` 만 사용하면 되고, AI API는 같은 오리진의 `/api/ai/v1` 로 호출됩니다. - -## 환경 변수 - -- `.env.example` 을 `.env` 로 복사 후 수정 -- `PORT=3100` (기본값) -- PostgreSQL: `DB_*` -- JWT: `JWT_SECRET`, `JWT_REFRESH_SECRET` -- LLM: `GEMINI_API_KEY` 등 diff --git a/ai-assistant/package-lock.json b/ai-assistant/package-lock.json deleted file mode 100644 index 5cc0f755..00000000 --- a/ai-assistant/package-lock.json +++ /dev/null @@ -1,3455 +0,0 @@ -{ - "name": "ai-assistant-api", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "ai-assistant-api", - "version": "1.0.0", - "dependencies": { - "@google/genai": "^1.0.0", - "axios": "^1.6.0", - "bcryptjs": "^2.4.3", - "compression": "^1.7.4", - "cors": "^2.8.5", - "dotenv": "^16.4.1", - "express": "^4.18.2", - "express-rate-limit": "^7.1.5", - "express-validator": "^7.0.1", - "helmet": "^7.1.0", - "jsonwebtoken": "^9.0.2", - "pg": "^8.11.3", - "pg-hstore": "^2.3.4", - "sequelize": "^6.35.2", - "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1", - "uuid": "^9.0.1", - "winston": "^3.11.0", - "zod": "^3.22.4" - }, - "devDependencies": { - "nodemon": "^3.0.3" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@apidevtools/json-schema-ref-parser": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", - "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", - "license": "MIT", - "dependencies": { - "@jsdevtools/ono": "^7.1.3", - "@types/json-schema": "^7.0.6", - "call-me-maybe": "^1.0.1", - "js-yaml": "^4.1.0" - } - }, - "node_modules/@apidevtools/openapi-schemas": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", - "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/@apidevtools/swagger-methods": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", - "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", - "license": "MIT" - }, - "node_modules/@apidevtools/swagger-parser": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", - "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", - "license": "MIT", - "dependencies": { - "@apidevtools/json-schema-ref-parser": "^9.0.6", - "@apidevtools/openapi-schemas": "^2.0.4", - "@apidevtools/swagger-methods": "^3.0.2", - "@jsdevtools/ono": "^7.1.3", - "call-me-maybe": "^1.0.1", - "z-schema": "^5.0.1" - }, - "peerDependencies": { - "openapi-types": ">=7" - } - }, - "node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/@dabh/diagnostics": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", - "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", - "license": "MIT", - "dependencies": { - "@so-ric/colorspace": "^1.1.6", - "enabled": "2.0.x", - "kuler": "^2.0.0" - } - }, - "node_modules/@google/genai": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.44.0.tgz", - "integrity": "sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A==", - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", - "license": "MIT" - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, - "node_modules/@scarf/scarf": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", - "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", - "hasInstallScript": true, - "license": "Apache-2.0" - }, - "node_modules/@so-ric/colorspace": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", - "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", - "license": "MIT", - "dependencies": { - "color": "^5.0.2", - "text-hex": "1.0.x" - } - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", - "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT" - }, - "node_modules/@types/triple-beam": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", - "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", - "license": "MIT" - }, - "node_modules/@types/validator": { - "version": "13.15.10", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", - "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", - "license": "MIT" - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bcryptjs": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", - "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", - "license": "MIT" - }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-me-maybe": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", - "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", - "license": "MIT" - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/color": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", - "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", - "license": "MIT", - "dependencies": { - "color-convert": "^3.1.3", - "color-string": "^2.1.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/color-convert": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", - "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=14.6" - } - }, - "node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/color-string": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", - "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", - "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "license": "MIT", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dottie": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz", - "integrity": "sha512-7lAK2A0b3zZr3UC5aE69CPdCFR4RHW1o2Dr74TqFykxkUCBXSRJum/yPc7g8zRHJqWKomPLHwFLLoUnn8PXXRg==", - "license": "MIT" - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/enabled": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", - "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", - "peer": true, - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/express-validator": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", - "integrity": "sha512-IGenaSf+DnWc69lKuqlRE9/i/2t5/16VpH5bXoqdxWz1aCpRvEdrBuu1y95i/iL5QP8ZYVATiwLFhwk3EDl5vg==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.21", - "validator": "~13.15.23" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/fecha": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", - "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", - "license": "MIT" - }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fn.name": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", - "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", - "license": "MIT" - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gaxios": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", - "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2", - "rimraf": "^5.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/google-auth-library": { - "version": "10.6.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", - "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "7.1.3", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/helmet": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", - "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/https-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true, - "license": "ISC" - }, - "node_modules/inflection": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", - "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", - "engines": [ - "node >= 0.4.0" - ], - "license": "MIT" - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, - "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "license": "MIT", - "dependencies": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jsonwebtoken/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/kuler": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", - "license": "MIT" - }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", - "license": "MIT" - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT" - }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, - "node_modules/logform": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", - "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", - "license": "MIT", - "dependencies": { - "@colors/colors": "1.6.0", - "@types/triple-beam": "^1.3.2", - "fecha": "^4.2.0", - "ms": "^2.1.1", - "safe-stable-stringify": "^2.3.1", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/logform/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/moment-timezone": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", - "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", - "license": "MIT", - "dependencies": { - "moment": "^2.29.4" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/nodemon": { - "version": "3.1.14", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", - "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^10.2.1", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/nodemon/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/nodemon/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/one-time": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", - "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", - "license": "MIT", - "dependencies": { - "fn.name": "1.x.x" - } - }, - "node_modules/openapi-types": { - "version": "12.1.3", - "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", - "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "license": "MIT", - "peer": true - }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" - }, - "node_modules/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", - "license": "MIT", - "peer": true, - "dependencies": { - "pg-connection-string": "^2.12.0", - "pg-pool": "^3.13.0", - "pg-protocol": "^1.13.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.3.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", - "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", - "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", - "license": "MIT" - }, - "node_modules/pg-hstore": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/pg-hstore/-/pg-hstore-2.3.4.tgz", - "integrity": "sha512-N3SGs/Rf+xA1M2/n0JBiXFDVMzdekwLZLAO0g7mpDY9ouX+fDI7jS6kTq3JujmYbtNSJ53TJ0q4G98KVZSM4EA==", - "license": "MIT", - "dependencies": { - "underscore": "^1.13.1" - }, - "engines": { - "node": ">= 0.8.x" - } - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", - "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", - "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true, - "license": "MIT" - }, - "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/retry-as-promised": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", - "integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==", - "license": "MIT" - }, - "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/sequelize": { - "version": "6.37.7", - "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.7.tgz", - "integrity": "sha512-mCnh83zuz7kQxxJirtFD7q6Huy6liPanI67BSlbzSYgVNl5eXVdE2CN1FuAeZwG1SNpGsNRCV+bJAVVnykZAFA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/sequelize" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.1.8", - "@types/validator": "^13.7.17", - "debug": "^4.3.4", - "dottie": "^2.0.6", - "inflection": "^1.13.4", - "lodash": "^4.17.21", - "moment": "^2.29.4", - "moment-timezone": "^0.5.43", - "pg-connection-string": "^2.6.1", - "retry-as-promised": "^7.0.4", - "semver": "^7.5.4", - "sequelize-pool": "^7.1.0", - "toposort-class": "^1.0.1", - "uuid": "^8.3.2", - "validator": "^13.9.0", - "wkx": "^0.5.0" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependenciesMeta": { - "ibm_db": { - "optional": true - }, - "mariadb": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "oracledb": { - "optional": true - }, - "pg": { - "optional": true - }, - "pg-hstore": { - "optional": true - }, - "snowflake-sdk": { - "optional": true - }, - "sqlite3": { - "optional": true - }, - "tedious": { - "optional": true - } - } - }, - "node_modules/sequelize-pool": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", - "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/sequelize/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/sequelize/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/sequelize/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/swagger-jsdoc": { - "version": "6.2.8", - "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", - "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", - "license": "MIT", - "dependencies": { - "commander": "6.2.0", - "doctrine": "3.0.0", - "glob": "7.1.6", - "lodash.mergewith": "^4.6.2", - "swagger-parser": "^10.0.3", - "yaml": "2.0.0-1" - }, - "bin": { - "swagger-jsdoc": "bin/swagger-jsdoc.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/swagger-jsdoc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/swagger-jsdoc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/swagger-jsdoc/node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/swagger-jsdoc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/swagger-parser": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", - "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", - "license": "MIT", - "dependencies": { - "@apidevtools/swagger-parser": "10.0.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/swagger-ui-dist": { - "version": "5.32.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.0.tgz", - "integrity": "sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==", - "license": "Apache-2.0", - "dependencies": { - "@scarf/scarf": "=1.4.0" - } - }, - "node_modules/swagger-ui-express": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", - "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", - "license": "MIT", - "dependencies": { - "swagger-ui-dist": ">=5.0.0" - }, - "engines": { - "node": ">= v0.10.32" - }, - "peerDependencies": { - "express": ">=4.0.0 || >=5.0.0-beta" - } - }, - "node_modules/text-hex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", - "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", - "license": "MIT" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/toposort-class": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", - "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", - "license": "MIT" - }, - "node_modules/touch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "dev": true, - "license": "ISC", - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, - "node_modules/triple-beam": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", - "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true, - "license": "MIT" - }, - "node_modules/underscore": { - "version": "1.13.8", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", - "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/validator": { - "version": "13.15.26", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", - "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/winston": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", - "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", - "license": "MIT", - "dependencies": { - "@colors/colors": "^1.6.0", - "@dabh/diagnostics": "^2.0.8", - "async": "^3.2.3", - "is-stream": "^2.0.0", - "logform": "^2.7.0", - "one-time": "^1.0.0", - "readable-stream": "^3.4.0", - "safe-stable-stringify": "^2.3.1", - "stack-trace": "0.0.x", - "triple-beam": "^1.3.0", - "winston-transport": "^4.9.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/winston-transport": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", - "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", - "license": "MIT", - "dependencies": { - "logform": "^2.7.0", - "readable-stream": "^3.6.2", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/wkx": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", - "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/yaml": { - "version": "2.0.0-1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", - "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/z-schema": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", - "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", - "license": "MIT", - "dependencies": { - "lodash.get": "^4.4.2", - "lodash.isequal": "^4.5.0", - "validator": "^13.7.0" - }, - "bin": { - "z-schema": "bin/z-schema" - }, - "engines": { - "node": ">=8.0.0" - }, - "optionalDependencies": { - "commander": "^9.4.1" - } - }, - "node_modules/z-schema/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/ai-assistant/package.json b/ai-assistant/package.json deleted file mode 100644 index 58fcec47..00000000 --- a/ai-assistant/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "ai-assistant-api", - "version": "1.0.0", - "description": "AI Assistant API (VEXPLOR 내장) - 포트 3100에서 구동, backend-node가 /api/ai/v1 로 프록시", - "private": true, - "main": "src/app.js", - "scripts": { - "start": "node src/app.js", - "dev": "nodemon src/app.js" - }, - "dependencies": { - "@google/genai": "^1.0.0", - "axios": "^1.6.0", - "bcryptjs": "^2.4.3", - "compression": "^1.7.4", - "cors": "^2.8.5", - "dotenv": "^16.4.1", - "express": "^4.18.2", - "express-rate-limit": "^7.1.5", - "express-validator": "^7.0.1", - "helmet": "^7.1.0", - "jsonwebtoken": "^9.0.2", - "pg": "^8.11.3", - "pg-hstore": "^2.3.4", - "sequelize": "^6.35.2", - "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1", - "uuid": "^9.0.1", - "winston": "^3.11.0", - "zod": "^3.22.4" - }, - "devDependencies": { - "nodemon": "^3.0.3" - }, - "engines": { - "node": ">=18.0.0" - } -} diff --git a/ai-assistant/src/app.js b/ai-assistant/src/app.js deleted file mode 100644 index 845217c6..00000000 --- a/ai-assistant/src/app.js +++ /dev/null @@ -1,186 +0,0 @@ -// src/app.js -// AI Assistant API 서버 메인 엔트리포인트 - -require('dotenv').config(); - -const express = require('express'); -const cors = require('cors'); -const helmet = require('helmet'); -const compression = require('compression'); -const rateLimit = require('express-rate-limit'); -const swaggerUi = require('swagger-ui-express'); -const swaggerSpec = require('./config/swagger.config'); - -const logger = require('./config/logger.config'); -const { sequelize } = require('./models'); -const routes = require('./routes'); -const errorHandler = require('./middlewares/error-handler.middleware'); - -const app = express(); -// VEXPLOR 내장 시 backend-node가 이 포트로 프록시하므로 기본 3100 사용 -const PORT = process.env.PORT || 3100; - -// =========================================== -// 미들웨어 설정 -// =========================================== - -// Trust proxy (Docker/Nginx 환경) -app.set('trust proxy', 1); - -// CORS 설정 (helmet보다 먼저 설정) -app.use(cors({ - origin: true, // 모든 origin 허용 - credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], -})); - -// Preflight 요청 처리 -app.options('*', cors()); - -// 보안 헤더 (CORS 이후에 설정) -app.use(helmet({ - crossOriginResourcePolicy: { policy: 'cross-origin' }, - crossOriginOpenerPolicy: { policy: 'unsafe-none' }, -})); - -// 요청 본문 파싱 -app.use(express.json({ limit: '10mb' })); -app.use(express.urlencoded({ extended: true })); - -// 압축 -app.use(compression()); - -// Rate Limiting (전역) -const limiter = rateLimit({ - windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000, - max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 100, - message: { - success: false, - error: { - code: 'RATE_LIMIT_EXCEEDED', - message: '요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.', - }, - }, - standardHeaders: true, - legacyHeaders: false, -}); -app.use(limiter); - -// 요청 로깅 -app.use((req, res, next) => { - const start = Date.now(); - res.on('finish', () => { - const duration = Date.now() - start; - logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`); - }); - next(); -}); - -// =========================================== -// 헬스 체크 -// =========================================== - -app.get('/health', (req, res) => { - res.json({ - success: true, - data: { - status: 'healthy', - timestamp: new Date().toISOString(), - uptime: process.uptime(), - }, - }); -}); - -// =========================================== -// Swagger API 문서 -// =========================================== - -app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { - explorer: true, - customCss: '.swagger-ui .topbar { display: none }', - customSiteTitle: 'AI Assistant API 문서', - swaggerOptions: { - persistAuthorization: true, - displayRequestDuration: true, - }, -})); - -// Swagger JSON -app.get('/api-docs.json', (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.send(swaggerSpec); -}); - -// =========================================== -// API 라우트 -// =========================================== - -app.use('/api/v1', routes); - -// =========================================== -// 404 처리 -// =========================================== - -app.use((req, res) => { - res.status(404).json({ - success: false, - error: { - code: 'NOT_FOUND', - message: `요청한 리소스를 찾을 수 없습니다: ${req.method} ${req.originalUrl}`, - }, - }); -}); - -// =========================================== -// 에러 핸들러 -// =========================================== - -app.use(errorHandler); - -// =========================================== -// 서버 시작 -// =========================================== - -async function startServer() { - try { - // 데이터베이스 연결 - await sequelize.authenticate(); - logger.info('✅ 데이터베이스 연결 성공'); - - // 테이블 동기화 (테이블이 없으면 생성) - await sequelize.sync(); - logger.info('✅ 데이터베이스 스키마 동기화 완료'); - - // 초기 데이터 설정 (관리자 계정, LLM 프로바이더) - const initService = require('./services/init.service'); - await initService.initialize(); - - // 서버 시작 - app.listen(PORT, () => { - logger.info(`🚀 AI Assistant API 서버가 포트 ${PORT}에서 실행 중입니다`); - logger.info(`📚 API 문서 (Swagger): http://localhost:${PORT}/api-docs`); - logger.info(`📚 API 엔드포인트: http://localhost:${PORT}/api/v1`); - }); - } catch (error) { - logger.error('❌ 서버 시작 실패:', error); - process.exit(1); - } -} - -// 프로세스 종료 처리 -process.on('SIGTERM', async () => { - logger.info('SIGTERM 신호 수신, 서버 종료 중...'); - await sequelize.close(); - process.exit(0); -}); - -process.on('SIGINT', async () => { - logger.info('SIGINT 신호 수신, 서버 종료 중...'); - await sequelize.close(); - process.exit(0); -}); - -startServer(); - -module.exports = app; diff --git a/ai-assistant/src/controllers/admin.controller.js b/ai-assistant/src/controllers/admin.controller.js deleted file mode 100644 index 6973a892..00000000 --- a/ai-assistant/src/controllers/admin.controller.js +++ /dev/null @@ -1,474 +0,0 @@ -// src/controllers/admin.controller.js -// 관리자 컨트롤러 - -const { LLMProvider, User, UsageLog, ApiKey } = require('../models'); -const { Op } = require('sequelize'); -const logger = require('../config/logger.config'); - -// ===== LLM 프로바이더 관리 ===== - -/** - * LLM 프로바이더 목록 조회 - */ -exports.getProviders = async (req, res, next) => { - try { - const providers = await LLMProvider.findAll({ - order: [['priority', 'ASC']], - attributes: [ - 'id', - 'name', - 'displayName', - 'endpoint', - 'modelName', - 'priority', - 'maxTokens', - 'temperature', - 'timeoutMs', - 'costPer1kInputTokens', - 'costPer1kOutputTokens', - 'isActive', - 'isHealthy', - 'lastHealthCheck', - 'createdAt', - 'updatedAt', - // API 키는 마스킹해서 반환 - 'apiKey', - ], - }); - - // API 키 마스킹 - const maskedProviders = providers.map((p) => { - const data = p.toJSON(); - if (data.apiKey) { - // 앞 8자만 보여주고 나머지는 마스킹 - data.apiKey = data.apiKey.substring(0, 8) + '****' + data.apiKey.slice(-4); - data.hasApiKey = true; - } else { - data.hasApiKey = false; - } - return data; - }); - - return res.json({ - success: true, - data: maskedProviders, - }); - } catch (error) { - return next(error); - } -}; - -/** - * LLM 프로바이더 추가 - */ -exports.createProvider = async (req, res, next) => { - try { - const { - name, - displayName, - endpoint, - apiKey, - modelName, - priority = 50, - maxTokens = 4096, - temperature = 0.7, - timeoutMs = 60000, - costPer1kInputTokens = 0, - costPer1kOutputTokens = 0, - } = req.body; - - // 중복 이름 확인 - const existing = await LLMProvider.findOne({ where: { name } }); - if (existing) { - return res.status(409).json({ - success: false, - error: { - code: 'PROVIDER_EXISTS', - message: '이미 존재하는 프로바이더 이름입니다.', - }, - }); - } - - const provider = await LLMProvider.create({ - name, - displayName, - endpoint, - apiKey, - modelName, - priority, - maxTokens, - temperature, - timeoutMs, - costPer1kInputTokens, - costPer1kOutputTokens, - isActive: true, - isHealthy: true, - }); - - logger.info(`LLM 프로바이더 추가: ${name} (${modelName})`); - - return res.status(201).json({ - success: true, - data: { - id: provider.id, - name: provider.name, - displayName: provider.displayName, - modelName: provider.modelName, - priority: provider.priority, - isActive: provider.isActive, - message: 'LLM 프로바이더가 추가되었습니다.', - }, - }); - } catch (error) { - return next(error); - } -}; - -/** - * LLM 프로바이더 수정 - */ -exports.updateProvider = async (req, res, next) => { - try { - const { id } = req.params; - const updates = req.body; - - const provider = await LLMProvider.findByPk(id); - if (!provider) { - return res.status(404).json({ - success: false, - error: { - code: 'PROVIDER_NOT_FOUND', - message: 'LLM 프로바이더를 찾을 수 없습니다.', - }, - }); - } - - // 허용된 필드만 업데이트 - const allowedFields = [ - 'displayName', - 'endpoint', - 'apiKey', - 'modelName', - 'priority', - 'maxTokens', - 'temperature', - 'timeoutMs', - 'costPer1kInputTokens', - 'costPer1kOutputTokens', - 'isActive', - 'isHealthy', - ]; - - allowedFields.forEach((field) => { - if (updates[field] !== undefined) { - provider[field] = updates[field]; - } - }); - - await provider.save(); - - logger.info(`LLM 프로바이더 수정: ${provider.name}`); - - return res.json({ - success: true, - data: { - id: provider.id, - name: provider.name, - displayName: provider.displayName, - modelName: provider.modelName, - isActive: provider.isActive, - message: 'LLM 프로바이더가 수정되었습니다.', - }, - }); - } catch (error) { - return next(error); - } -}; - -/** - * LLM 프로바이더 삭제 - */ -exports.deleteProvider = async (req, res, next) => { - try { - const { id } = req.params; - - const provider = await LLMProvider.findByPk(id); - if (!provider) { - return res.status(404).json({ - success: false, - error: { - code: 'PROVIDER_NOT_FOUND', - message: 'LLM 프로바이더를 찾을 수 없습니다.', - }, - }); - } - - const providerName = provider.name; - await provider.destroy(); - - logger.info(`LLM 프로바이더 삭제: ${providerName}`); - - return res.json({ - success: true, - data: { - message: 'LLM 프로바이더가 삭제되었습니다.', - }, - }); - } catch (error) { - return next(error); - } -}; - -// ===== 사용자 관리 ===== - -/** - * 사용자 목록 조회 - */ -exports.getUsers = async (req, res, next) => { - try { - const page = parseInt(req.query.page, 10) || 1; - const limit = parseInt(req.query.limit, 10) || 100; - const offset = (page - 1) * limit; - - const { count, rows: users } = await User.findAndCountAll({ - attributes: [ - 'id', - 'email', - 'name', - 'role', - 'status', - 'plan', - 'monthlyTokenLimit', - 'lastLoginAt', - 'createdAt', - ], - order: [['createdAt', 'DESC']], - limit, - offset, - }); - - // 페이지네이션 없이 간단한 배열로 반환 (프론트엔드 호환) - return res.json({ - success: true, - data: users, - }); - } catch (error) { - return next(error); - } -}; - -/** - * 사용자 정보 수정 - */ -exports.updateUser = async (req, res, next) => { - try { - const { id } = req.params; - const { role, status, plan, monthlyTokenLimit } = req.body; - - const user = await User.findByPk(id); - if (!user) { - return res.status(404).json({ - success: false, - error: { - code: 'USER_NOT_FOUND', - message: '사용자를 찾을 수 없습니다.', - }, - }); - } - - if (role) user.role = role; - if (status) user.status = status; - if (plan) user.plan = plan; - if (monthlyTokenLimit !== undefined) user.monthlyTokenLimit = monthlyTokenLimit; - - await user.save(); - - logger.info(`사용자 정보 수정: ${user.email} (role: ${user.role}, status: ${user.status})`); - - return res.json({ - success: true, - data: user.toSafeJSON(), - }); - } catch (error) { - return next(error); - } -}; - -// ===== 시스템 통계 ===== - -/** - * 사용자별 사용량 통계 - */ -exports.getUsageByUser = async (req, res, next) => { - try { - const days = parseInt(req.query.days, 10) || 7; - const startDate = new Date(); - startDate.setDate(startDate.getDate() - days); - startDate.setHours(0, 0, 0, 0); - - // 사용자별 집계 (raw SQL 사용) - const userStats = await UsageLog.sequelize.query(` - SELECT - u.id as "userId", - u.email, - u.name, - COALESCE(SUM(ul.total_tokens), 0) as "totalTokens", - COALESCE(SUM(ul.cost_usd), 0) as "totalCost", - COUNT(ul.id) as "requestCount" - FROM users u - LEFT JOIN usage_logs ul ON u.id = ul.user_id AND ul.created_at >= :startDate - GROUP BY u.id, u.email, u.name - HAVING COUNT(ul.id) > 0 - ORDER BY SUM(ul.total_tokens) DESC NULLS LAST - `, { - replacements: { startDate }, - type: UsageLog.sequelize.QueryTypes.SELECT, - }); - - // 데이터 정리 - const result = userStats.map((stat) => ({ - userId: stat.userId, - email: stat.email || 'Unknown', - name: stat.name || '', - totalTokens: parseInt(stat.totalTokens, 10) || 0, - totalCost: parseFloat(stat.totalCost) || 0, - requestCount: parseInt(stat.requestCount, 10) || 0, - })); - - return res.json({ - success: true, - data: result, - }); - } catch (error) { - return next(error); - } -}; - -/** - * 프로바이더별 사용량 통계 - */ -exports.getUsageByProvider = async (req, res, next) => { - try { - const days = parseInt(req.query.days, 10) || 7; - const startDate = new Date(); - startDate.setDate(startDate.getDate() - days); - startDate.setHours(0, 0, 0, 0); - - // 프로바이더별 집계 (컬럼명 수정: providerName, modelName) - const providerStats = await UsageLog.findAll({ - where: { - createdAt: { [Op.gte]: startDate }, - }, - attributes: [ - 'providerName', - 'modelName', - [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'], - [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('prompt_tokens')), 'promptTokens'], - [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('completion_tokens')), 'completionTokens'], - [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'], - [UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'], - [UsageLog.sequelize.fn('AVG', UsageLog.sequelize.col('response_time_ms')), 'avgResponseTime'], - ], - group: ['providerName', 'modelName'], - order: [[UsageLog.sequelize.literal('"totalTokens"'), 'DESC']], - raw: true, - }); - - // 데이터 정리 - const result = providerStats.map((stat) => ({ - provider: stat.providerName || 'Unknown', - model: stat.modelName || 'Unknown', - totalTokens: parseInt(stat.totalTokens, 10) || 0, - promptTokens: parseInt(stat.promptTokens, 10) || 0, - completionTokens: parseInt(stat.completionTokens, 10) || 0, - totalCost: parseFloat(stat.totalCost) || 0, - requestCount: parseInt(stat.requestCount, 10) || 0, - avgResponseTime: Math.round(parseFloat(stat.avgResponseTime) || 0), - })); - - return res.json({ - success: true, - data: result, - }); - } catch (error) { - return next(error); - } -}; - -/** - * 시스템 통계 조회 - */ -exports.getStats = async (req, res, next) => { - try { - // 전체 사용자 수 - const totalUsers = await User.count(); - const activeUsers = await User.count({ where: { status: 'active' } }); - - // 전체 API 키 수 - const totalApiKeys = await ApiKey.count(); - const activeApiKeys = await ApiKey.count({ where: { status: 'active' } }); - - // 오늘 사용량 - const today = new Date(); - today.setHours(0, 0, 0, 0); - const todayUsage = await UsageLog.findOne({ - where: { - createdAt: { [Op.gte]: today }, - }, - attributes: [ - [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'], - [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'], - [UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'], - ], - raw: true, - }); - - // 이번 달 사용량 - const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); - const monthlyUsage = await UsageLog.findOne({ - where: { - createdAt: { [Op.gte]: monthStart }, - }, - attributes: [ - [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'], - [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'], - [UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'], - ], - raw: true, - }); - - // 활성 프로바이더 수 - const activeProviders = await LLMProvider.count({ where: { isActive: true, isHealthy: true } }); - - return res.json({ - success: true, - data: { - users: { - total: totalUsers, - active: activeUsers, - }, - apiKeys: { - total: totalApiKeys, - active: activeApiKeys, - }, - providers: { - active: activeProviders, - }, - usage: { - today: { - tokens: parseInt(todayUsage?.totalTokens, 10) || 0, - cost: parseFloat(todayUsage?.totalCost) || 0, - requests: parseInt(todayUsage?.requestCount, 10) || 0, - }, - monthly: { - tokens: parseInt(monthlyUsage?.totalTokens, 10) || 0, - cost: parseFloat(monthlyUsage?.totalCost) || 0, - requests: parseInt(monthlyUsage?.requestCount, 10) || 0, - }, - }, - }, - }); - } catch (error) { - return next(error); - } -}; diff --git a/ai-assistant/src/controllers/api-key.controller.js b/ai-assistant/src/controllers/api-key.controller.js deleted file mode 100644 index 85d8061c..00000000 --- a/ai-assistant/src/controllers/api-key.controller.js +++ /dev/null @@ -1,215 +0,0 @@ -// src/controllers/api-key.controller.js -// API 키 컨트롤러 - -const { ApiKey } = require('../models'); -const logger = require('../config/logger.config'); - -/** - * API 키 발급 - */ -exports.create = async (req, res, next) => { - try { - const { name, expiresInDays, permissions } = req.body; - const userId = req.user.userId; - - // API 키 생성 - const rawKey = ApiKey.generateKey(); - const keyHash = ApiKey.hashKey(rawKey); - const keyPrefix = rawKey.substring(0, 12); - - // 만료 일시 계산 - let expiresAt = null; - if (expiresInDays) { - expiresAt = new Date(); - expiresAt.setDate(expiresAt.getDate() + expiresInDays); - } - - const apiKey = await ApiKey.create({ - userId, - name, - keyPrefix, - keyHash, - permissions: permissions || ['chat:read', 'chat:write'], - expiresAt, - }); - - logger.info(`API 키 발급: ${name} (user: ${userId})`); - - // 주의: 원본 키는 이 응답에서만 반환됨 (다시 조회 불가) - return res.status(201).json({ - success: true, - data: { - id: apiKey.id, - name: apiKey.name, - key: rawKey, // 원본 키 (한 번만 표시) - keyPrefix: apiKey.keyPrefix, - permissions: apiKey.permissions, - expiresAt: apiKey.expiresAt, - createdAt: apiKey.createdAt, - message: '⚠️ API 키는 이 응답에서만 확인할 수 있습니다. 안전한 곳에 저장하세요.', - }, - }); - } catch (error) { - return next(error); - } -}; - -/** - * API 키 목록 조회 - */ -exports.list = async (req, res, next) => { - try { - const userId = req.user.userId; - - const apiKeys = await ApiKey.findAll({ - where: { userId }, - attributes: [ - 'id', - 'name', - 'keyPrefix', - 'permissions', - 'rateLimit', - 'status', - 'expiresAt', - 'lastUsedAt', - 'totalRequests', - 'createdAt', - ], - order: [['createdAt', 'DESC']], - }); - - return res.json({ - success: true, - data: apiKeys, - }); - } catch (error) { - return next(error); - } -}; - -/** - * API 키 상세 조회 - */ -exports.get = async (req, res, next) => { - try { - const { id } = req.params; - const userId = req.user.userId; - - const apiKey = await ApiKey.findOne({ - where: { id, userId }, - attributes: [ - 'id', - 'name', - 'keyPrefix', - 'permissions', - 'rateLimit', - 'status', - 'expiresAt', - 'lastUsedAt', - 'totalRequests', - 'createdAt', - 'updatedAt', - ], - }); - - if (!apiKey) { - return res.status(404).json({ - success: false, - error: { - code: 'API_KEY_NOT_FOUND', - message: 'API 키를 찾을 수 없습니다.', - }, - }); - } - - return res.json({ - success: true, - data: apiKey, - }); - } catch (error) { - return next(error); - } -}; - -/** - * API 키 수정 - */ -exports.update = async (req, res, next) => { - try { - const { id } = req.params; - const { name, status } = req.body; - const userId = req.user.userId; - - const apiKey = await ApiKey.findOne({ - where: { id, userId }, - }); - - if (!apiKey) { - return res.status(404).json({ - success: false, - error: { - code: 'API_KEY_NOT_FOUND', - message: 'API 키를 찾을 수 없습니다.', - }, - }); - } - - if (name) apiKey.name = name; - if (status) apiKey.status = status; - - await apiKey.save(); - - logger.info(`API 키 수정: ${apiKey.name} (id: ${id})`); - - return res.json({ - success: true, - data: { - id: apiKey.id, - name: apiKey.name, - keyPrefix: apiKey.keyPrefix, - status: apiKey.status, - updatedAt: apiKey.updatedAt, - }, - }); - } catch (error) { - return next(error); - } -}; - -/** - * API 키 폐기 - */ -exports.revoke = async (req, res, next) => { - try { - const { id } = req.params; - const userId = req.user.userId; - - const apiKey = await ApiKey.findOne({ - where: { id, userId }, - }); - - if (!apiKey) { - return res.status(404).json({ - success: false, - error: { - code: 'API_KEY_NOT_FOUND', - message: 'API 키를 찾을 수 없습니다.', - }, - }); - } - - apiKey.status = 'revoked'; - await apiKey.save(); - - logger.info(`API 키 폐기: ${apiKey.name} (id: ${id})`); - - return res.json({ - success: true, - data: { - message: 'API 키가 폐기되었습니다.', - }, - }); - } catch (error) { - return next(error); - } -}; diff --git a/ai-assistant/src/controllers/auth.controller.js b/ai-assistant/src/controllers/auth.controller.js deleted file mode 100644 index 73746415..00000000 --- a/ai-assistant/src/controllers/auth.controller.js +++ /dev/null @@ -1,195 +0,0 @@ -// src/controllers/auth.controller.js -// 인증 컨트롤러 - -const jwt = require('jsonwebtoken'); -const { User } = require('../models'); -const logger = require('../config/logger.config'); - -const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; -const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; -const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-refresh-secret'; -const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '30d'; - -/** - * JWT 토큰 생성 - */ -function generateTokens(user) { - const accessToken = jwt.sign( - { userId: user.id, email: user.email, role: user.role }, - JWT_SECRET, - { expiresIn: JWT_EXPIRES_IN } - ); - - const refreshToken = jwt.sign( - { userId: user.id }, - JWT_REFRESH_SECRET, - { expiresIn: JWT_REFRESH_EXPIRES_IN } - ); - - return { accessToken, refreshToken }; -} - -/** - * 회원가입 - */ -exports.register = async (req, res, next) => { - try { - const { email, password, name } = req.body; - - // 이메일 중복 확인 - const existingUser = await User.findOne({ where: { email } }); - if (existingUser) { - return res.status(409).json({ - success: false, - error: { - code: 'EMAIL_ALREADY_EXISTS', - message: '이미 등록된 이메일입니다.', - }, - }); - } - - // 사용자 생성 - const user = await User.create({ - email, - password, - name, - }); - - // 토큰 생성 - const tokens = generateTokens(user); - - logger.info(`새 사용자 가입: ${email}`); - - return res.status(201).json({ - success: true, - data: { - user: user.toSafeJSON(), - ...tokens, - }, - }); - } catch (error) { - return next(error); - } -}; - -/** - * 로그인 - */ -exports.login = async (req, res, next) => { - try { - const { email, password } = req.body; - - // 사용자 조회 - const user = await User.findOne({ where: { email } }); - if (!user) { - return res.status(401).json({ - success: false, - error: { - code: 'INVALID_CREDENTIALS', - message: '이메일 또는 비밀번호가 올바르지 않습니다.', - }, - }); - } - - // 비밀번호 검증 - const isValidPassword = await user.validatePassword(password); - if (!isValidPassword) { - return res.status(401).json({ - success: false, - error: { - code: 'INVALID_CREDENTIALS', - message: '이메일 또는 비밀번호가 올바르지 않습니다.', - }, - }); - } - - // 계정 상태 확인 - if (user.status !== 'active') { - return res.status(403).json({ - success: false, - error: { - code: 'ACCOUNT_INACTIVE', - message: '계정이 비활성화되었습니다. 관리자에게 문의하세요.', - }, - }); - } - - // 마지막 로그인 시간 업데이트 - user.lastLoginAt = new Date(); - await user.save(); - - // 토큰 생성 - const tokens = generateTokens(user); - - logger.info(`사용자 로그인: ${email}`); - - return res.json({ - success: true, - data: { - user: user.toSafeJSON(), - ...tokens, - }, - }); - } catch (error) { - return next(error); - } -}; - -/** - * 토큰 갱신 - */ -exports.refresh = async (req, res, next) => { - try { - const { refreshToken } = req.body; - - // 리프레시 토큰 검증 - let decoded; - try { - decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET); - } catch (error) { - return res.status(401).json({ - success: false, - error: { - code: 'INVALID_REFRESH_TOKEN', - message: '유효하지 않은 리프레시 토큰입니다.', - }, - }); - } - - // 사용자 조회 - const user = await User.findByPk(decoded.userId); - if (!user || user.status !== 'active') { - return res.status(401).json({ - success: false, - error: { - code: 'USER_NOT_FOUND', - message: '사용자를 찾을 수 없습니다.', - }, - }); - } - - // 새 토큰 생성 - const tokens = generateTokens(user); - - return res.json({ - success: true, - data: tokens, - }); - } catch (error) { - return next(error); - } -}; - -/** - * 로그아웃 - */ -exports.logout = async (req, res) => { - // 클라이언트에서 토큰 삭제 처리 - // 서버에서는 특별한 처리 없음 (필요시 블랙리스트 구현) - return res.json({ - success: true, - data: { - message: '로그아웃되었습니다.', - }, - }); -}; diff --git a/ai-assistant/src/controllers/chat.controller.js b/ai-assistant/src/controllers/chat.controller.js deleted file mode 100644 index e93d2f98..00000000 --- a/ai-assistant/src/controllers/chat.controller.js +++ /dev/null @@ -1,152 +0,0 @@ -// src/controllers/chat.controller.js -// 채팅 컨트롤러 (OpenAI 호환 API) - -const llmService = require('../services/llm.service'); -const logger = require('../config/logger.config'); - -/** - * 채팅 완성 API (OpenAI 호환) - * POST /api/v1/chat/completions - */ -exports.completions = async (req, res, next) => { - try { - const { - model = 'gemini-2.0-flash', - messages, - temperature = 0.7, - max_tokens = 4096, - stream = false, - } = req.body; - - const startTime = Date.now(); - - // 스트리밍 응답 처리 - if (stream) { - return handleStreamingResponse(req, res, { - model, - messages, - temperature, - maxTokens: max_tokens, - }); - } - - // 일반 응답 처리 - const result = await llmService.chat({ - model, - messages, - temperature, - maxTokens: max_tokens, - userId: req.user.id, - apiKeyId: req.apiKey?.id, - }); - - const responseTime = Date.now() - startTime; - - // 사용량 정보 저장 (미들웨어에서 처리) - req.usageData = { - providerId: result.providerId, - providerName: result.provider, - modelName: result.model, - promptTokens: result.usage.promptTokens, - completionTokens: result.usage.completionTokens, - totalTokens: result.usage.totalTokens, - costUsd: result.cost, - responseTimeMs: responseTime, - success: true, - }; - - // OpenAI 호환 응답 형식 - return res.json({ - id: `chatcmpl-${Date.now()}`, - object: 'chat.completion', - created: Math.floor(Date.now() / 1000), - model: result.model, - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: result.text, - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: result.usage.promptTokens, - completion_tokens: result.usage.completionTokens, - total_tokens: result.usage.totalTokens, - }, - }); - } catch (error) { - logger.error('채팅 완성 오류:', error); - - // 사용량 정보 저장 (실패) - req.usageData = { - success: false, - errorMessage: error.message, - }; - - return next(error); - } -}; - -/** - * 스트리밍 응답 처리 - */ -async function handleStreamingResponse(req, res, params) { - const { model, messages, temperature, maxTokens } = params; - - // SSE 헤더 설정 - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - - try { - // 스트리밍 응답 생성 - const stream = await llmService.chatStream({ - model, - messages, - temperature, - maxTokens, - userId: req.user.id, - apiKeyId: req.apiKey?.id, - }); - - // 스트림 이벤트 처리 - for await (const chunk of stream) { - const data = { - id: `chatcmpl-${Date.now()}`, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: { - content: chunk.text, - }, - finish_reason: chunk.done ? 'stop' : null, - }, - ], - }; - - res.write(`data: ${JSON.stringify(data)}\n\n`); - } - - // 스트림 종료 - res.write('data: [DONE]\n\n'); - res.end(); - } catch (error) { - logger.error('스트리밍 오류:', error); - - const errorData = { - error: { - message: error.message, - type: 'server_error', - }, - }; - - res.write(`data: ${JSON.stringify(errorData)}\n\n`); - res.end(); - } -} diff --git a/ai-assistant/src/controllers/model.controller.js b/ai-assistant/src/controllers/model.controller.js deleted file mode 100644 index 68df5824..00000000 --- a/ai-assistant/src/controllers/model.controller.js +++ /dev/null @@ -1,67 +0,0 @@ -// src/controllers/model.controller.js -// 모델 컨트롤러 - -const { LLMProvider } = require('../models'); - -/** - * 사용 가능한 모델 목록 조회 - */ -exports.list = async (req, res, next) => { - try { - const providers = await LLMProvider.getActiveProviders(); - - // OpenAI 호환 형식으로 변환 - const models = providers.map((provider) => ({ - id: provider.modelName, - object: 'model', - created: Math.floor(new Date(provider.createdAt).getTime() / 1000), - owned_by: provider.name, - permission: [], - root: provider.modelName, - parent: null, - })); - - return res.json({ - object: 'list', - data: models, - }); - } catch (error) { - return next(error); - } -}; - -/** - * 모델 상세 정보 조회 - */ -exports.get = async (req, res, next) => { - try { - const { id } = req.params; - - const provider = await LLMProvider.findOne({ - where: { modelName: id, isActive: true }, - }); - - if (!provider) { - return res.status(404).json({ - error: { - message: `모델 '${id}'을(를) 찾을 수 없습니다.`, - type: 'invalid_request_error', - param: 'model', - code: 'model_not_found', - }, - }); - } - - return res.json({ - id: provider.modelName, - object: 'model', - created: Math.floor(new Date(provider.createdAt).getTime() / 1000), - owned_by: provider.name, - permission: [], - root: provider.modelName, - parent: null, - }); - } catch (error) { - return next(error); - } -}; diff --git a/ai-assistant/src/controllers/usage.controller.js b/ai-assistant/src/controllers/usage.controller.js deleted file mode 100644 index ba436919..00000000 --- a/ai-assistant/src/controllers/usage.controller.js +++ /dev/null @@ -1,177 +0,0 @@ -// src/controllers/usage.controller.js -// 사용량 컨트롤러 - -const { UsageLog, User } = require('../models'); -const { Op } = require('sequelize'); - -/** - * 사용량 요약 조회 - */ -exports.getSummary = async (req, res, next) => { - try { - const userId = req.user.userId; - - // 사용자 정보 조회 - const user = await User.findByPk(userId); - if (!user) { - return res.status(404).json({ - success: false, - error: { - code: 'USER_NOT_FOUND', - message: '사용자를 찾을 수 없습니다.', - }, - }); - } - - // 이번 달 사용량 - const now = new Date(); - const monthlyUsage = await UsageLog.getMonthlyTotalByUser( - userId, - now.getFullYear(), - now.getMonth() + 1 - ); - - // 오늘 사용량 - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const todayEnd = new Date(todayStart); - todayEnd.setDate(todayEnd.getDate() + 1); - - const todayUsage = await UsageLog.findOne({ - where: { - userId, - createdAt: { - [Op.between]: [todayStart, todayEnd], - }, - }, - attributes: [ - [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('total_tokens')), 'totalTokens'], - [UsageLog.sequelize.fn('SUM', UsageLog.sequelize.col('cost_usd')), 'totalCost'], - [UsageLog.sequelize.fn('COUNT', UsageLog.sequelize.col('id')), 'requestCount'], - ], - raw: true, - }); - - return res.json({ - success: true, - data: { - plan: user.plan, - limit: { - monthly: user.monthlyTokenLimit, - remaining: Math.max(0, user.monthlyTokenLimit - monthlyUsage.totalTokens), - }, - usage: { - today: { - tokens: parseInt(todayUsage?.totalTokens, 10) || 0, - cost: parseFloat(todayUsage?.totalCost) || 0, - requests: parseInt(todayUsage?.requestCount, 10) || 0, - }, - monthly: monthlyUsage, - }, - }, - }); - } catch (error) { - return next(error); - } -}; - -/** - * 일별 사용량 조회 - */ -exports.getDailyUsage = async (req, res, next) => { - try { - const userId = req.user.userId; - const { startDate, endDate } = req.query; - - // 기본값: 최근 30일 - const end = endDate ? new Date(endDate) : new Date(); - const start = startDate ? new Date(startDate) : new Date(end); - if (!startDate) { - start.setDate(start.getDate() - 30); - } - - const dailyUsage = await UsageLog.getDailyUsageByUser(userId, start, end); - - return res.json({ - success: true, - data: { - startDate: start.toISOString().split('T')[0], - endDate: end.toISOString().split('T')[0], - usage: dailyUsage, - }, - }); - } catch (error) { - return next(error); - } -}; - -/** - * 월별 사용량 조회 - */ -exports.getMonthlyUsage = async (req, res, next) => { - try { - const userId = req.user.userId; - const now = new Date(); - const year = parseInt(req.query.year, 10) || now.getFullYear(); - const month = parseInt(req.query.month, 10) || (now.getMonth() + 1); - - const monthlyUsage = await UsageLog.getMonthlyTotalByUser(userId, year, month); - - return res.json({ - success: true, - data: { - year, - month, - usage: monthlyUsage, - }, - }); - } catch (error) { - return next(error); - } -}; - -/** - * 사용량 로그 목록 조회 - */ -exports.getLogs = async (req, res, next) => { - try { - const userId = req.user.userId; - const page = parseInt(req.query.page, 10) || 1; - const limit = parseInt(req.query.limit, 10) || 20; - const offset = (page - 1) * limit; - - const { count, rows: logs } = await UsageLog.findAndCountAll({ - where: { userId }, - attributes: [ - 'id', - 'providerName', - 'modelName', - 'promptTokens', - 'completionTokens', - 'totalTokens', - 'costUsd', - 'responseTimeMs', - 'success', - 'errorMessage', - 'createdAt', - ], - order: [['createdAt', 'DESC']], - limit, - offset, - }); - - return res.json({ - success: true, - data: { - logs, - pagination: { - total: count, - page, - limit, - totalPages: Math.ceil(count / limit), - }, - }, - }); - } catch (error) { - return next(error); - } -}; diff --git a/ai-assistant/src/controllers/user.controller.js b/ai-assistant/src/controllers/user.controller.js deleted file mode 100644 index b498478d..00000000 --- a/ai-assistant/src/controllers/user.controller.js +++ /dev/null @@ -1,113 +0,0 @@ -// src/controllers/user.controller.js -// 사용자 컨트롤러 - -const { User, UsageLog } = require('../models'); -const logger = require('../config/logger.config'); - -/** - * 내 정보 조회 - */ -exports.getMe = async (req, res, next) => { - try { - const user = await User.findByPk(req.user.userId); - if (!user) { - return res.status(404).json({ - success: false, - error: { - code: 'USER_NOT_FOUND', - message: '사용자를 찾을 수 없습니다.', - }, - }); - } - - // 이번 달 사용량 조회 - const now = new Date(); - const monthlyUsage = await UsageLog.getMonthlyTotalByUser( - user.id, - now.getFullYear(), - now.getMonth() + 1 - ); - - return res.json({ - success: true, - data: { - ...user.toSafeJSON(), - usage: { - monthly: monthlyUsage, - limit: user.monthlyTokenLimit, - remaining: Math.max(0, user.monthlyTokenLimit - monthlyUsage.totalTokens), - }, - }, - }); - } catch (error) { - return next(error); - } -}; - -/** - * 내 정보 수정 - */ -exports.updateMe = async (req, res, next) => { - try { - const { name, password } = req.body; - - const user = await User.findByPk(req.user.userId); - if (!user) { - return res.status(404).json({ - success: false, - error: { - code: 'USER_NOT_FOUND', - message: '사용자를 찾을 수 없습니다.', - }, - }); - } - - // 업데이트할 필드만 설정 - if (name) user.name = name; - if (password) user.password = password; - - await user.save(); - - logger.info(`사용자 정보 수정: ${user.email}`); - - return res.json({ - success: true, - data: user.toSafeJSON(), - }); - } catch (error) { - return next(error); - } -}; - -/** - * 계정 삭제 - */ -exports.deleteMe = async (req, res, next) => { - try { - const user = await User.findByPk(req.user.userId); - if (!user) { - return res.status(404).json({ - success: false, - error: { - code: 'USER_NOT_FOUND', - message: '사용자를 찾을 수 없습니다.', - }, - }); - } - - // 소프트 삭제 (상태 변경) - user.status = 'inactive'; - await user.save(); - - logger.info(`사용자 계정 삭제: ${user.email}`); - - return res.json({ - success: true, - data: { - message: '계정이 삭제되었습니다.', - }, - }); - } catch (error) { - return next(error); - } -}; diff --git a/ai-assistant/src/middlewares/auth.middleware.js b/ai-assistant/src/middlewares/auth.middleware.js deleted file mode 100644 index bb19f293..00000000 --- a/ai-assistant/src/middlewares/auth.middleware.js +++ /dev/null @@ -1,257 +0,0 @@ -// src/middlewares/auth.middleware.js -// 인증 미들웨어 - -const jwt = require('jsonwebtoken'); -const { ApiKey, User } = require('../models'); -const logger = require('../config/logger.config'); - -const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; - -/** - * JWT 토큰 인증 미들웨어 - * Authorization: Bearer - */ -exports.authenticateJWT = async (req, res, next) => { - try { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ - success: false, - error: { - code: 'UNAUTHORIZED', - message: '인증 토큰이 필요합니다.', - }, - }); - } - - const token = authHeader.substring(7); - - try { - const decoded = jwt.verify(token, JWT_SECRET); - req.user = decoded; - return next(); - } catch (error) { - if (error.name === 'TokenExpiredError') { - return res.status(401).json({ - success: false, - error: { - code: 'TOKEN_EXPIRED', - message: '토큰이 만료되었습니다.', - }, - }); - } - - return res.status(401).json({ - success: false, - error: { - code: 'INVALID_TOKEN', - message: '유효하지 않은 토큰입니다.', - }, - }); - } - } catch (error) { - return next(error); - } -}; - -/** - * API 키 인증 미들웨어 - * Authorization: Bearer - */ -exports.authenticateApiKey = async (req, res, next) => { - try { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ - error: { - message: 'API 키가 필요합니다.', - type: 'invalid_request_error', - code: 'missing_api_key', - }, - }); - } - - const apiKeyValue = authHeader.substring(7); - - // API 키 접두사 확인 - const prefix = process.env.API_KEY_PREFIX || 'sk-'; - if (!apiKeyValue.startsWith(prefix)) { - return res.status(401).json({ - error: { - message: '유효하지 않은 API 키 형식입니다.', - type: 'invalid_request_error', - code: 'invalid_api_key', - }, - }); - } - - // API 키 조회 - const apiKey = await ApiKey.findByKey(apiKeyValue); - - if (!apiKey) { - return res.status(401).json({ - error: { - message: '유효하지 않은 API 키입니다.', - type: 'invalid_request_error', - code: 'invalid_api_key', - }, - }); - } - - // 만료 확인 - if (apiKey.isExpired()) { - return res.status(401).json({ - error: { - message: 'API 키가 만료되었습니다.', - type: 'invalid_request_error', - code: 'expired_api_key', - }, - }); - } - - // 사용자 상태 확인 - if (apiKey.user.status !== 'active') { - return res.status(403).json({ - error: { - message: '계정이 비활성화되었습니다.', - type: 'invalid_request_error', - code: 'account_inactive', - }, - }); - } - - // 사용 기록 업데이트 - await apiKey.recordUsage(); - - // 요청 객체에 사용자 및 API 키 정보 추가 - req.user = { - id: apiKey.user.id, - userId: apiKey.user.id, - email: apiKey.user.email, - role: apiKey.user.role, - plan: apiKey.user.plan, - }; - req.apiKey = apiKey; - - return next(); - } catch (error) { - logger.error('API 키 인증 오류:', error); - return next(error); - } -}; - -/** - * 관리자 권한 확인 미들웨어 - */ -exports.requireAdmin = (req, res, next) => { - if (req.user.role !== 'admin') { - return res.status(403).json({ - success: false, - error: { - code: 'FORBIDDEN', - message: '관리자 권한이 필요합니다.', - }, - }); - } - return next(); -}; - -/** - * JWT 또는 API 키 인증 미들웨어 - * JWT 토큰과 API 키 모두 허용 - */ -exports.authenticateAny = async (req, res, next) => { - try { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ - success: false, - error: { - code: 'UNAUTHORIZED', - message: '인증이 필요합니다.', - }, - }); - } - - const token = authHeader.substring(7); - const prefix = process.env.API_KEY_PREFIX || 'sk-'; - - // API 키인 경우 - if (token.startsWith(prefix)) { - const apiKey = await ApiKey.findByKey(token); - - if (!apiKey) { - return res.status(401).json({ - error: { - message: '유효하지 않은 API 키입니다.', - type: 'invalid_request_error', - code: 'invalid_api_key', - }, - }); - } - - if (apiKey.isExpired()) { - return res.status(401).json({ - error: { - message: 'API 키가 만료되었습니다.', - type: 'invalid_request_error', - code: 'expired_api_key', - }, - }); - } - - if (apiKey.user.status !== 'active') { - return res.status(403).json({ - error: { - message: '계정이 비활성화되었습니다.', - type: 'invalid_request_error', - code: 'account_inactive', - }, - }); - } - - await apiKey.recordUsage(); - - req.user = { - id: apiKey.user.id, - userId: apiKey.user.id, - email: apiKey.user.email, - role: apiKey.user.role, - plan: apiKey.user.plan, - }; - req.apiKey = apiKey; - - return next(); - } - - // JWT 토큰인 경우 - try { - const decoded = jwt.verify(token, JWT_SECRET); - req.user = decoded; - return next(); - } catch (error) { - if (error.name === 'TokenExpiredError') { - return res.status(401).json({ - success: false, - error: { - code: 'TOKEN_EXPIRED', - message: '토큰이 만료되었습니다.', - }, - }); - } - - return res.status(401).json({ - success: false, - error: { - code: 'INVALID_TOKEN', - message: '유효하지 않은 토큰입니다.', - }, - }); - } - } catch (error) { - return next(error); - } -}; diff --git a/ai-assistant/src/middlewares/error-handler.middleware.js b/ai-assistant/src/middlewares/error-handler.middleware.js deleted file mode 100644 index 56acce44..00000000 --- a/ai-assistant/src/middlewares/error-handler.middleware.js +++ /dev/null @@ -1,80 +0,0 @@ -// src/middlewares/error-handler.middleware.js -// 에러 핸들러 미들웨어 - -const logger = require('../config/logger.config'); - -/** - * 전역 에러 핸들러 - */ -module.exports = (err, req, res, _next) => { - // 에러 로깅 - logger.error('에러 발생:', { - message: err.message, - stack: err.stack, - path: req.path, - method: req.method, - }); - - // Sequelize 유효성 검사 에러 - if (err.name === 'SequelizeValidationError') { - return res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: '데이터 유효성 검사 실패', - details: err.errors.map((e) => ({ - field: e.path, - message: e.message, - })), - }, - }); - } - - // Sequelize 고유 제약 조건 에러 - if (err.name === 'SequelizeUniqueConstraintError') { - return res.status(409).json({ - success: false, - error: { - code: 'DUPLICATE_ENTRY', - message: '중복된 데이터가 존재합니다.', - details: err.errors.map((e) => ({ - field: e.path, - message: e.message, - })), - }, - }); - } - - // JWT 에러 - if (err.name === 'JsonWebTokenError') { - return res.status(401).json({ - success: false, - error: { - code: 'INVALID_TOKEN', - message: '유효하지 않은 토큰입니다.', - }, - }); - } - - // 기본 에러 응답 - const statusCode = err.statusCode || 500; - const message = err.message || '서버 오류가 발생했습니다.'; - - // 프로덕션 환경에서는 상세 에러 숨김 - const response = { - success: false, - error: { - code: err.code || 'INTERNAL_ERROR', - message: process.env.NODE_ENV === 'production' && statusCode === 500 - ? '서버 오류가 발생했습니다.' - : message, - }, - }; - - // 개발 환경에서는 스택 트레이스 포함 - if (process.env.NODE_ENV === 'development') { - response.error.stack = err.stack; - } - - return res.status(statusCode).json(response); -}; diff --git a/ai-assistant/src/middlewares/usage-logger.middleware.js b/ai-assistant/src/middlewares/usage-logger.middleware.js deleted file mode 100644 index 10096f87..00000000 --- a/ai-assistant/src/middlewares/usage-logger.middleware.js +++ /dev/null @@ -1,50 +0,0 @@ -// src/middlewares/usage-logger.middleware.js -// 사용량 로깅 미들웨어 - -const { UsageLog } = require('../models'); -const logger = require('../config/logger.config'); - -/** - * 사용량 로깅 미들웨어 - * 응답 완료 후 사용량 정보를 데이터베이스에 저장 - */ -exports.usageLogger = (req, res, next) => { - // 응답 완료 후 처리 - res.on('finish', async () => { - try { - // 사용량 데이터가 없으면 스킵 - if (!req.usageData) { - return; - } - - const usageData = { - userId: req.user?.id || req.user?.userId, - apiKeyId: req.apiKey?.id || null, - providerId: req.usageData.providerId || null, - providerName: req.usageData.providerName || null, - modelName: req.usageData.modelName || null, - promptTokens: req.usageData.promptTokens || 0, - completionTokens: req.usageData.completionTokens || 0, - totalTokens: req.usageData.totalTokens || 0, - costUsd: req.usageData.costUsd || 0, - responseTimeMs: req.usageData.responseTimeMs || null, - success: req.usageData.success !== false, - errorMessage: req.usageData.errorMessage || null, - requestIp: req.ip || req.connection?.remoteAddress, - userAgent: req.headers['user-agent'] || null, - }; - - await UsageLog.create(usageData); - - logger.debug('사용량 로그 저장:', { - userId: usageData.userId, - tokens: usageData.totalTokens, - cost: usageData.costUsd, - }); - } catch (error) { - logger.error('사용량 로그 저장 실패:', error); - } - }); - - next(); -}; diff --git a/ai-assistant/src/middlewares/validation.middleware.js b/ai-assistant/src/middlewares/validation.middleware.js deleted file mode 100644 index 23bf035f..00000000 --- a/ai-assistant/src/middlewares/validation.middleware.js +++ /dev/null @@ -1,30 +0,0 @@ -// src/middlewares/validation.middleware.js -// 유효성 검사 미들웨어 - -const { validationResult } = require('express-validator'); - -/** - * 요청 유효성 검사 결과 처리 - */ -exports.validateRequest = (req, res, next) => { - const errors = validationResult(req); - - if (!errors.isEmpty()) { - const formattedErrors = errors.array().map((error) => ({ - field: error.path, - message: error.msg, - value: error.value, - })); - - return res.status(400).json({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: '입력값이 올바르지 않습니다.', - details: formattedErrors, - }, - }); - } - - return next(); -}; diff --git a/ai-assistant/src/models/api-key.model.js b/ai-assistant/src/models/api-key.model.js deleted file mode 100644 index 772b6715..00000000 --- a/ai-assistant/src/models/api-key.model.js +++ /dev/null @@ -1,130 +0,0 @@ -// src/models/api-key.model.js -// API 키 모델 - -const { DataTypes } = require('sequelize'); -const crypto = require('crypto'); - -module.exports = (sequelize) => { - const ApiKey = sequelize.define('ApiKey', { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - userId: { - type: DataTypes.UUID, - allowNull: false, - references: { - model: 'users', - key: 'id', - }, - comment: '소유자 사용자 ID', - }, - name: { - type: DataTypes.STRING(100), - allowNull: false, - comment: 'API 키 이름 (사용자 지정)', - }, - keyPrefix: { - type: DataTypes.STRING(12), - allowNull: false, - comment: 'API 키 접두사 (표시용)', - }, - keyHash: { - type: DataTypes.STRING(64), - allowNull: false, - unique: true, - comment: 'API 키 해시 (SHA-256)', - }, - permissions: { - type: DataTypes.JSONB, - defaultValue: ['chat:read', 'chat:write'], - comment: '권한 목록', - }, - rateLimit: { - type: DataTypes.INTEGER, - defaultValue: 60, // 분당 60회 - comment: '분당 요청 제한', - }, - status: { - type: DataTypes.ENUM('active', 'revoked', 'expired'), - defaultValue: 'active', - comment: 'API 키 상태', - }, - expiresAt: { - type: DataTypes.DATE, - allowNull: true, - comment: '만료 일시 (null이면 무기한)', - }, - lastUsedAt: { - type: DataTypes.DATE, - allowNull: true, - comment: '마지막 사용 시간', - }, - totalRequests: { - type: DataTypes.INTEGER, - defaultValue: 0, - comment: '총 요청 수', - }, - }, { - tableName: 'api_keys', - timestamps: true, - underscored: true, - indexes: [ - { - fields: ['key_hash'], - unique: true, - }, - { - fields: ['user_id'], - }, - { - fields: ['status'], - }, - ], - }); - - // 클래스 메서드: API 키 생성 - ApiKey.generateKey = function() { - const prefix = process.env.API_KEY_PREFIX || 'sk-'; - const length = parseInt(process.env.API_KEY_LENGTH, 10) || 48; - const randomPart = crypto.randomBytes(length).toString('base64url').slice(0, length); - return `${prefix}${randomPart}`; - }; - - // 클래스 메서드: API 키 해시 생성 - ApiKey.hashKey = function(key) { - return crypto.createHash('sha256').update(key).digest('hex'); - }; - - // 클래스 메서드: API 키로 조회 - ApiKey.findByKey = async function(key) { - const keyHash = this.hashKey(key); - const apiKey = await this.findOne({ - where: { keyHash, status: 'active' }, - }); - - if (apiKey) { - // 사용자 정보 별도 조회 - const { User } = require('./index'); - apiKey.user = await User.findByPk(apiKey.userId); - } - - return apiKey; - }; - - // 인스턴스 메서드: 사용 기록 업데이트 - ApiKey.prototype.recordUsage = async function() { - this.lastUsedAt = new Date(); - this.totalRequests += 1; - await this.save(); - }; - - // 인스턴스 메서드: 만료 여부 확인 - ApiKey.prototype.isExpired = function() { - if (!this.expiresAt) return false; - return new Date() > this.expiresAt; - }; - - return ApiKey; -}; diff --git a/ai-assistant/src/models/index.js b/ai-assistant/src/models/index.js deleted file mode 100644 index 45f79258..00000000 --- a/ai-assistant/src/models/index.js +++ /dev/null @@ -1,55 +0,0 @@ -// src/models/index.js -// Sequelize 모델 인덱스 - -const { Sequelize } = require('sequelize'); -const config = require('../config/database.config'); - -const env = process.env.NODE_ENV || 'development'; -const dbConfig = config[env]; - -// Sequelize 인스턴스 생성 -const sequelize = new Sequelize( - dbConfig.database, - dbConfig.username, - dbConfig.password, - { - host: dbConfig.host, - port: dbConfig.port, - dialect: dbConfig.dialect, - logging: dbConfig.logging, - pool: dbConfig.pool, - dialectOptions: dbConfig.dialectOptions, - } -); - -// 모델 임포트 -const User = require('./user.model')(sequelize); -const ApiKey = require('./api-key.model')(sequelize); -const UsageLog = require('./usage-log.model')(sequelize); -const LLMProvider = require('./llm-provider.model')(sequelize); - -// 관계 설정 -// User - ApiKey (1:N) -User.hasMany(ApiKey, { foreignKey: 'userId', as: 'apiKeys' }); -ApiKey.belongsTo(User, { foreignKey: 'userId', as: 'user' }); - -// User - UsageLog (1:N) -User.hasMany(UsageLog, { foreignKey: 'userId', as: 'usageLogs' }); -UsageLog.belongsTo(User, { foreignKey: 'userId', as: 'user' }); - -// ApiKey - UsageLog (1:N) -ApiKey.hasMany(UsageLog, { foreignKey: 'apiKeyId', as: 'usageLogs' }); -UsageLog.belongsTo(ApiKey, { foreignKey: 'apiKeyId', as: 'apiKey' }); - -// LLMProvider - UsageLog (1:N) -LLMProvider.hasMany(UsageLog, { foreignKey: 'providerId', as: 'usageLogs' }); -UsageLog.belongsTo(LLMProvider, { foreignKey: 'providerId', as: 'provider' }); - -module.exports = { - sequelize, - Sequelize, - User, - ApiKey, - UsageLog, - LLMProvider, -}; diff --git a/ai-assistant/src/models/llm-provider.model.js b/ai-assistant/src/models/llm-provider.model.js deleted file mode 100644 index 68b0e443..00000000 --- a/ai-assistant/src/models/llm-provider.model.js +++ /dev/null @@ -1,143 +0,0 @@ -// src/models/llm-provider.model.js -// LLM 프로바이더 모델 - -const { DataTypes } = require('sequelize'); - -module.exports = (sequelize) => { - const LLMProvider = sequelize.define('LLMProvider', { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - name: { - type: DataTypes.STRING(50), - allowNull: false, - unique: true, - comment: '프로바이더 이름 (gemini, openai, claude 등)', - }, - displayName: { - type: DataTypes.STRING(100), - allowNull: false, - comment: '표시 이름', - }, - endpoint: { - type: DataTypes.STRING(500), - allowNull: true, - comment: 'API 엔드포인트 URL', - }, - apiKey: { - type: DataTypes.TEXT, - allowNull: true, - comment: 'API 키 (암호화 저장 권장)', - }, - modelName: { - type: DataTypes.STRING(100), - allowNull: false, - comment: '기본 모델 이름', - }, - priority: { - type: DataTypes.INTEGER, - defaultValue: 100, - comment: '우선순위 (낮을수록 우선)', - }, - maxTokens: { - type: DataTypes.INTEGER, - defaultValue: 4096, - comment: '최대 토큰 수', - }, - temperature: { - type: DataTypes.FLOAT, - defaultValue: 0.7, - comment: '기본 온도', - }, - timeoutMs: { - type: DataTypes.INTEGER, - defaultValue: 60000, - comment: '타임아웃 (밀리초)', - }, - costPer1kInputTokens: { - type: DataTypes.DECIMAL(10, 6), - defaultValue: 0, - comment: '입력 토큰 1K당 비용 (USD)', - }, - costPer1kOutputTokens: { - type: DataTypes.DECIMAL(10, 6), - defaultValue: 0, - comment: '출력 토큰 1K당 비용 (USD)', - }, - isActive: { - type: DataTypes.BOOLEAN, - defaultValue: true, - comment: '활성화 여부', - }, - isHealthy: { - type: DataTypes.BOOLEAN, - defaultValue: true, - comment: '건강 상태', - }, - lastHealthCheck: { - type: DataTypes.DATE, - allowNull: true, - comment: '마지막 헬스 체크 시간', - }, - healthCheckUrl: { - type: DataTypes.STRING(500), - allowNull: true, - comment: '헬스 체크 URL', - }, - config: { - type: DataTypes.JSONB, - defaultValue: {}, - comment: '추가 설정', - }, - }, { - tableName: 'llm_providers', - timestamps: true, - underscored: true, - indexes: [ - { - fields: ['name'], - unique: true, - }, - { - fields: ['priority'], - }, - { - fields: ['is_active', 'is_healthy'], - }, - ], - }); - - // 클래스 메서드: 활성 프로바이더 목록 조회 (우선순위 순) - LLMProvider.getActiveProviders = async function() { - return this.findAll({ - where: { isActive: true }, - order: [['priority', 'ASC']], - }); - }; - - // 클래스 메서드: 건강한 프로바이더 목록 조회 - LLMProvider.getHealthyProviders = async function() { - return this.findAll({ - where: { isActive: true, isHealthy: true }, - order: [['priority', 'ASC']], - }); - }; - - // 인스턴스 메서드: 헬스 상태 업데이트 - LLMProvider.prototype.updateHealth = async function(isHealthy) { - this.isHealthy = isHealthy; - this.lastHealthCheck = new Date(); - await this.save(); - }; - - // 인스턴스 메서드: 비용 계산 - LLMProvider.prototype.calculateCost = function(promptTokens, completionTokens) { - const inputCost = (promptTokens / 1000) * parseFloat(this.costPer1kInputTokens || 0); - const outputCost = (completionTokens / 1000) * parseFloat(this.costPer1kOutputTokens || 0); - return inputCost + outputCost; - }; - - return LLMProvider; -}; diff --git a/ai-assistant/src/models/usage-log.model.js b/ai-assistant/src/models/usage-log.model.js deleted file mode 100644 index 161e7a1f..00000000 --- a/ai-assistant/src/models/usage-log.model.js +++ /dev/null @@ -1,164 +0,0 @@ -// src/models/usage-log.model.js -// 사용량 로그 모델 - -const { DataTypes, Op } = require('sequelize'); - -module.exports = (sequelize) => { - const UsageLog = sequelize.define('UsageLog', { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - userId: { - type: DataTypes.UUID, - allowNull: false, - references: { - model: 'users', - key: 'id', - }, - comment: '사용자 ID', - }, - apiKeyId: { - type: DataTypes.UUID, - allowNull: true, - references: { - model: 'api_keys', - key: 'id', - }, - comment: 'API 키 ID', - }, - providerId: { - type: DataTypes.UUID, - allowNull: true, - references: { - model: 'llm_providers', - key: 'id', - }, - comment: 'LLM 프로바이더 ID', - }, - providerName: { - type: DataTypes.STRING(50), - allowNull: true, - comment: 'LLM 프로바이더 이름', - }, - modelName: { - type: DataTypes.STRING(100), - allowNull: true, - comment: '사용된 모델 이름', - }, - promptTokens: { - type: DataTypes.INTEGER, - defaultValue: 0, - comment: '프롬프트 토큰 수', - }, - completionTokens: { - type: DataTypes.INTEGER, - defaultValue: 0, - comment: '완성 토큰 수', - }, - totalTokens: { - type: DataTypes.INTEGER, - defaultValue: 0, - comment: '총 토큰 수', - }, - costUsd: { - type: DataTypes.DECIMAL(10, 6), - defaultValue: 0, - comment: '비용 (USD)', - }, - responseTimeMs: { - type: DataTypes.INTEGER, - allowNull: true, - comment: '응답 시간 (밀리초)', - }, - success: { - type: DataTypes.BOOLEAN, - defaultValue: true, - comment: '성공 여부', - }, - errorMessage: { - type: DataTypes.TEXT, - allowNull: true, - comment: '에러 메시지', - }, - requestIp: { - type: DataTypes.STRING(45), - allowNull: true, - comment: '요청 IP 주소', - }, - userAgent: { - type: DataTypes.STRING(500), - allowNull: true, - comment: 'User-Agent', - }, - }, { - tableName: 'usage_logs', - timestamps: true, - underscored: true, - indexes: [ - { - fields: ['user_id'], - }, - { - fields: ['api_key_id'], - }, - { - fields: ['created_at'], - }, - { - fields: ['provider_name'], - }, - ], - }); - - // 클래스 메서드: 사용자별 일별 사용량 조회 - UsageLog.getDailyUsageByUser = async function(userId, startDate, endDate) { - return this.findAll({ - where: { - userId, - createdAt: { - [Op.between]: [startDate, endDate], - }, - }, - attributes: [ - [sequelize.fn('DATE', sequelize.col('created_at')), 'date'], - [sequelize.fn('SUM', sequelize.col('total_tokens')), 'totalTokens'], - [sequelize.fn('SUM', sequelize.col('cost_usd')), 'totalCost'], - [sequelize.fn('COUNT', sequelize.col('id')), 'requestCount'], - ], - group: [sequelize.fn('DATE', sequelize.col('created_at'))], - order: [[sequelize.fn('DATE', sequelize.col('created_at')), 'ASC']], - raw: true, - }); - }; - - // 클래스 메서드: 사용자별 월간 총 사용량 조회 - UsageLog.getMonthlyTotalByUser = async function(userId, year, month) { - const startDate = new Date(year, month - 1, 1); - const endDate = new Date(year, month, 0, 23, 59, 59); - - const result = await this.findOne({ - where: { - userId, - createdAt: { - [Op.between]: [startDate, endDate], - }, - }, - attributes: [ - [sequelize.fn('SUM', sequelize.col('total_tokens')), 'totalTokens'], - [sequelize.fn('SUM', sequelize.col('cost_usd')), 'totalCost'], - [sequelize.fn('COUNT', sequelize.col('id')), 'requestCount'], - ], - raw: true, - }); - - return { - totalTokens: parseInt(result.totalTokens, 10) || 0, - totalCost: parseFloat(result.totalCost) || 0, - requestCount: parseInt(result.requestCount, 10) || 0, - }; - }; - - return UsageLog; -}; diff --git a/ai-assistant/src/models/user.model.js b/ai-assistant/src/models/user.model.js deleted file mode 100644 index 85fe08a5..00000000 --- a/ai-assistant/src/models/user.model.js +++ /dev/null @@ -1,92 +0,0 @@ -// src/models/user.model.js -// 사용자 모델 - -const { DataTypes } = require('sequelize'); -const bcrypt = require('bcryptjs'); - -module.exports = (sequelize) => { - const User = sequelize.define('User', { - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true, - }, - email: { - type: DataTypes.STRING(255), - allowNull: false, - unique: true, - validate: { - isEmail: true, - }, - comment: '이메일 (로그인 ID)', - }, - password: { - type: DataTypes.STRING(255), - allowNull: false, - comment: '비밀번호 (해시)', - }, - name: { - type: DataTypes.STRING(100), - allowNull: false, - comment: '사용자 이름', - }, - role: { - type: DataTypes.ENUM('user', 'admin'), - defaultValue: 'user', - comment: '역할 (user: 일반 사용자, admin: 관리자)', - }, - status: { - type: DataTypes.ENUM('active', 'inactive', 'suspended'), - defaultValue: 'active', - comment: '계정 상태', - }, - plan: { - type: DataTypes.ENUM('free', 'basic', 'pro', 'enterprise'), - defaultValue: 'free', - comment: '요금제 플랜', - }, - monthlyTokenLimit: { - type: DataTypes.INTEGER, - defaultValue: 100000, // 무료 플랜 기본 10만 토큰 - comment: '월간 토큰 한도', - }, - lastLoginAt: { - type: DataTypes.DATE, - allowNull: true, - comment: '마지막 로그인 시간', - }, - }, { - tableName: 'users', - timestamps: true, - underscored: true, - hooks: { - // 비밀번호 해싱 - beforeCreate: async (user) => { - if (user.password) { - const salt = await bcrypt.genSalt(10); - user.password = await bcrypt.hash(user.password, salt); - } - }, - beforeUpdate: async (user) => { - if (user.changed('password')) { - const salt = await bcrypt.genSalt(10); - user.password = await bcrypt.hash(user.password, salt); - } - }, - }, - }); - - // 인스턴스 메서드: 비밀번호 검증 - User.prototype.validatePassword = async function(password) { - return bcrypt.compare(password, this.password); - }; - - // 인스턴스 메서드: 안전한 JSON 변환 (비밀번호 제외) - User.prototype.toSafeJSON = function() { - const values = { ...this.get() }; - delete values.password; - return values; - }; - - return User; -}; diff --git a/ai-assistant/src/routes/admin.routes.js b/ai-assistant/src/routes/admin.routes.js deleted file mode 100644 index abb2854f..00000000 --- a/ai-assistant/src/routes/admin.routes.js +++ /dev/null @@ -1,151 +0,0 @@ -// src/routes/admin.routes.js -// 관리자 라우트 - -const express = require('express'); -const { body, param } = require('express-validator'); -const adminController = require('../controllers/admin.controller'); -const { authenticateJWT, requireAdmin } = require('../middlewares/auth.middleware'); -const { validateRequest } = require('../middlewares/validation.middleware'); - -const router = express.Router(); - -// 모든 라우트에 JWT 인증 + 관리자 권한 필요 -router.use(authenticateJWT); -router.use(requireAdmin); - -// ===== LLM 프로바이더 관리 ===== - -/** - * GET /api/v1/admin/providers - * LLM 프로바이더 목록 조회 - */ -router.get('/providers', adminController.getProviders); - -/** - * POST /api/v1/admin/providers - * LLM 프로바이더 추가 - */ -router.post( - '/providers', - [ - body('name') - .trim() - .isLength({ min: 1, max: 50 }) - .withMessage('프로바이더 이름은 1-50자 사이여야 합니다'), - body('displayName') - .trim() - .isLength({ min: 1, max: 100 }) - .withMessage('표시 이름은 1-100자 사이여야 합니다'), - body('modelName') - .trim() - .isLength({ min: 1, max: 100 }) - .withMessage('모델 이름은 1-100자 사이여야 합니다'), - body('apiKey') - .optional() - .isString(), - body('priority') - .optional() - .isInt({ min: 1, max: 100 }), - validateRequest, - ], - adminController.createProvider -); - -/** - * PATCH /api/v1/admin/providers/:id - * LLM 프로바이더 수정 (API 키 설정 포함) - */ -router.patch( - '/providers/:id', - [ - param('id') - .isUUID() - .withMessage('유효한 프로바이더 ID가 아닙니다'), - body('apiKey') - .optional() - .isString(), - body('modelName') - .optional() - .isString(), - body('isActive') - .optional() - .isBoolean(), - body('priority') - .optional() - .isInt({ min: 1, max: 100 }), - validateRequest, - ], - adminController.updateProvider -); - -/** - * DELETE /api/v1/admin/providers/:id - * LLM 프로바이더 삭제 - */ -router.delete( - '/providers/:id', - [ - param('id') - .isUUID() - .withMessage('유효한 프로바이더 ID가 아닙니다'), - validateRequest, - ], - adminController.deleteProvider -); - -// ===== 사용자 관리 ===== - -/** - * GET /api/v1/admin/users - * 사용자 목록 조회 - */ -router.get('/users', adminController.getUsers); - -/** - * PATCH /api/v1/admin/users/:id - * 사용자 정보 수정 (역할, 상태, 플랜 등) - */ -router.patch( - '/users/:id', - [ - param('id') - .isUUID() - .withMessage('유효한 사용자 ID가 아닙니다'), - body('role') - .optional() - .isIn(['user', 'admin']), - body('status') - .optional() - .isIn(['active', 'inactive', 'suspended']), - body('plan') - .optional() - .isIn(['free', 'basic', 'pro', 'enterprise']), - body('monthlyTokenLimit') - .optional() - .isInt({ min: 0 }), - validateRequest, - ], - adminController.updateUser -); - -// ===== 시스템 통계 ===== - -/** - * GET /api/v1/admin/stats - * 시스템 통계 조회 - */ -router.get('/stats', adminController.getStats); - -/** - * GET /api/v1/admin/usage/by-user - * 사용자별 사용량 통계 - */ -router.get('/usage/by-user', adminController.getUsageByUser); - -/** - * GET /api/v1/admin/usage/by-provider - * 프로바이더별 사용량 통계 - */ -router.get('/usage/by-provider', adminController.getUsageByProvider); - -module.exports = router; diff --git a/ai-assistant/src/routes/api-key.routes.js b/ai-assistant/src/routes/api-key.routes.js deleted file mode 100644 index c45b2d9c..00000000 --- a/ai-assistant/src/routes/api-key.routes.js +++ /dev/null @@ -1,99 +0,0 @@ -// src/routes/api-key.routes.js -// API 키 라우트 - -const express = require('express'); -const { body, param } = require('express-validator'); -const apiKeyController = require('../controllers/api-key.controller'); -const { authenticateJWT } = require('../middlewares/auth.middleware'); -const { validateRequest } = require('../middlewares/validation.middleware'); - -const router = express.Router(); - -// 모든 라우트에 JWT 인증 적용 -router.use(authenticateJWT); - -/** - * POST /api/v1/api-keys - * API 키 발급 - */ -router.post( - '/', - [ - body('name') - .trim() - .isLength({ min: 1, max: 100 }) - .withMessage('API 키 이름은 1-100자 사이여야 합니다'), - body('expiresInDays') - .optional() - .isInt({ min: 1, max: 365 }) - .withMessage('만료 기간은 1-365일 사이여야 합니다'), - body('permissions') - .optional() - .isArray() - .withMessage('권한은 배열이어야 합니다'), - validateRequest, - ], - apiKeyController.create -); - -/** - * GET /api/v1/api-keys - * API 키 목록 조회 - */ -router.get('/', apiKeyController.list); - -/** - * GET /api/v1/api-keys/:id - * API 키 상세 조회 - */ -router.get( - '/:id', - [ - param('id') - .isUUID() - .withMessage('유효한 API 키 ID가 아닙니다'), - validateRequest, - ], - apiKeyController.get -); - -/** - * PATCH /api/v1/api-keys/:id - * API 키 수정 - */ -router.patch( - '/:id', - [ - param('id') - .isUUID() - .withMessage('유효한 API 키 ID가 아닙니다'), - body('name') - .optional() - .trim() - .isLength({ min: 1, max: 100 }) - .withMessage('API 키 이름은 1-100자 사이여야 합니다'), - body('status') - .optional() - .isIn(['active', 'revoked']) - .withMessage('상태는 active 또는 revoked여야 합니다'), - validateRequest, - ], - apiKeyController.update -); - -/** - * DELETE /api/v1/api-keys/:id - * API 키 폐기 - */ -router.delete( - '/:id', - [ - param('id') - .isUUID() - .withMessage('유효한 API 키 ID가 아닙니다'), - validateRequest, - ], - apiKeyController.revoke -); - -module.exports = router; diff --git a/ai-assistant/src/routes/auth.routes.js b/ai-assistant/src/routes/auth.routes.js deleted file mode 100644 index 416b0c52..00000000 --- a/ai-assistant/src/routes/auth.routes.js +++ /dev/null @@ -1,76 +0,0 @@ -// src/routes/auth.routes.js -// 인증 라우트 - -const express = require('express'); -const { body } = require('express-validator'); -const authController = require('../controllers/auth.controller'); -const { validateRequest } = require('../middlewares/validation.middleware'); - -const router = express.Router(); - -/** - * POST /api/v1/auth/register - * 회원가입 - */ -router.post( - '/register', - [ - body('email') - .isEmail() - .normalizeEmail() - .withMessage('유효한 이메일 주소를 입력해주세요'), - body('password') - .isLength({ min: 8 }) - .withMessage('비밀번호는 최소 8자 이상이어야 합니다') - .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) - .withMessage('비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다'), - body('name') - .trim() - .isLength({ min: 2, max: 100 }) - .withMessage('이름은 2-100자 사이여야 합니다'), - validateRequest, - ], - authController.register -); - -/** - * POST /api/v1/auth/login - * 로그인 - */ -router.post( - '/login', - [ - body('email') - .isEmail() - .normalizeEmail() - .withMessage('유효한 이메일 주소를 입력해주세요'), - body('password') - .notEmpty() - .withMessage('비밀번호를 입력해주세요'), - validateRequest, - ], - authController.login -); - -/** - * POST /api/v1/auth/refresh - * 토큰 갱신 - */ -router.post( - '/refresh', - [ - body('refreshToken') - .notEmpty() - .withMessage('리프레시 토큰을 입력해주세요'), - validateRequest, - ], - authController.refresh -); - -/** - * POST /api/v1/auth/logout - * 로그아웃 - */ -router.post('/logout', authController.logout); - -module.exports = router; diff --git a/ai-assistant/src/routes/chat.routes.js b/ai-assistant/src/routes/chat.routes.js deleted file mode 100644 index ac8d1ae3..00000000 --- a/ai-assistant/src/routes/chat.routes.js +++ /dev/null @@ -1,55 +0,0 @@ -// src/routes/chat.routes.js -// 채팅 API 라우트 (OpenAI 호환) - -const express = require('express'); -const { body } = require('express-validator'); -const chatController = require('../controllers/chat.controller'); -const { authenticateAny } = require('../middlewares/auth.middleware'); -const { validateRequest } = require('../middlewares/validation.middleware'); -const { usageLogger } = require('../middlewares/usage-logger.middleware'); - -const router = express.Router(); - -/** - * POST /api/v1/chat/completions - * 채팅 완성 API (OpenAI 호환) - * - * 인증: Bearer API_KEY 또는 JWT 토큰 - */ -router.post( - '/completions', - authenticateAny, - [ - body('model') - .optional() - .isString() - .withMessage('모델은 문자열이어야 합니다'), - body('messages') - .isArray({ min: 1 }) - .withMessage('메시지 배열이 필요합니다'), - body('messages.*.role') - .isIn(['system', 'user', 'assistant']) - .withMessage('메시지 역할은 system, user, assistant 중 하나여야 합니다'), - body('messages.*.content') - .isString() - .notEmpty() - .withMessage('메시지 내용이 필요합니다'), - body('temperature') - .optional() - .isFloat({ min: 0, max: 2 }) - .withMessage('온도는 0-2 사이여야 합니다'), - body('max_tokens') - .optional() - .isInt({ min: 1, max: 128000 }) - .withMessage('최대 토큰은 1-128000 사이여야 합니다'), - body('stream') - .optional() - .isBoolean() - .withMessage('스트림은 불리언이어야 합니다'), - validateRequest, - ], - usageLogger, - chatController.completions -); - -module.exports = router; diff --git a/ai-assistant/src/routes/index.js b/ai-assistant/src/routes/index.js deleted file mode 100644 index ddcd9158..00000000 --- a/ai-assistant/src/routes/index.js +++ /dev/null @@ -1,45 +0,0 @@ -// src/routes/index.js -// API 라우트 인덱스 - -const express = require('express'); -const authRoutes = require('./auth.routes'); -const userRoutes = require('./user.routes'); -const apiKeyRoutes = require('./api-key.routes'); -const chatRoutes = require('./chat.routes'); -const usageRoutes = require('./usage.routes'); -const modelRoutes = require('./model.routes'); -const adminRoutes = require('./admin.routes'); - -const router = express.Router(); - -// API 정보 -router.get('/', (req, res) => { - res.json({ - success: true, - data: { - name: 'AI Assistant API', - version: '1.0.0', - description: 'LLM API Platform - OpenAI 호환 API', - endpoints: { - auth: '/api/v1/auth', - users: '/api/v1/users', - apiKeys: '/api/v1/api-keys', - chat: '/api/v1/chat', - models: '/api/v1/models', - usage: '/api/v1/usage', - }, - documentation: 'https://docs.example.com', - }, - }); -}); - -// 라우트 등록 -router.use('/auth', authRoutes); -router.use('/users', userRoutes); -router.use('/api-keys', apiKeyRoutes); -router.use('/chat', chatRoutes); -router.use('/models', modelRoutes); -router.use('/usage', usageRoutes); -router.use('/admin', adminRoutes); - -module.exports = router; diff --git a/ai-assistant/src/routes/model.routes.js b/ai-assistant/src/routes/model.routes.js deleted file mode 100644 index edd828c8..00000000 --- a/ai-assistant/src/routes/model.routes.js +++ /dev/null @@ -1,24 +0,0 @@ -// src/routes/model.routes.js -// 모델 라우트 - -const express = require('express'); -const modelController = require('../controllers/model.controller'); -const { authenticateAny } = require('../middlewares/auth.middleware'); - -const router = express.Router(); - -/** - * GET /api/v1/models - * 사용 가능한 모델 목록 조회 - * JWT 토큰 또는 API 키로 인증 - */ -router.get('/', authenticateAny, modelController.list); - -/** - * GET /api/v1/models/:id - * 모델 상세 정보 조회 - * JWT 토큰 또는 API 키로 인증 - */ -router.get('/:id', authenticateAny, modelController.get); - -module.exports = router; diff --git a/ai-assistant/src/routes/usage.routes.js b/ai-assistant/src/routes/usage.routes.js deleted file mode 100644 index e8cd0049..00000000 --- a/ai-assistant/src/routes/usage.routes.js +++ /dev/null @@ -1,81 +0,0 @@ -// src/routes/usage.routes.js -// 사용량 라우트 - -const express = require('express'); -const { query } = require('express-validator'); -const usageController = require('../controllers/usage.controller'); -const { authenticateJWT } = require('../middlewares/auth.middleware'); -const { validateRequest } = require('../middlewares/validation.middleware'); - -const router = express.Router(); - -// 모든 라우트에 JWT 인증 적용 -router.use(authenticateJWT); - -/** - * GET /api/v1/usage - * 사용량 요약 조회 - */ -router.get('/', usageController.getSummary); - -/** - * GET /api/v1/usage/daily - * 일별 사용량 조회 - */ -router.get( - '/daily', - [ - query('startDate') - .optional() - .isISO8601() - .withMessage('시작 날짜는 ISO 8601 형식이어야 합니다'), - query('endDate') - .optional() - .isISO8601() - .withMessage('종료 날짜는 ISO 8601 형식이어야 합니다'), - validateRequest, - ], - usageController.getDailyUsage -); - -/** - * GET /api/v1/usage/monthly - * 월별 사용량 조회 - */ -router.get( - '/monthly', - [ - query('year') - .optional() - .isInt({ min: 2020, max: 2100 }) - .withMessage('연도는 2020-2100 사이여야 합니다'), - query('month') - .optional() - .isInt({ min: 1, max: 12 }) - .withMessage('월은 1-12 사이여야 합니다'), - validateRequest, - ], - usageController.getMonthlyUsage -); - -/** - * GET /api/v1/usage/logs - * 사용량 로그 목록 조회 - */ -router.get( - '/logs', - [ - query('page') - .optional() - .isInt({ min: 1 }) - .withMessage('페이지는 1 이상이어야 합니다'), - query('limit') - .optional() - .isInt({ min: 1, max: 100 }) - .withMessage('한도는 1-100 사이여야 합니다'), - validateRequest, - ], - usageController.getLogs -); - -module.exports = router; diff --git a/ai-assistant/src/routes/user.routes.js b/ai-assistant/src/routes/user.routes.js deleted file mode 100644 index 953c9ebe..00000000 --- a/ai-assistant/src/routes/user.routes.js +++ /dev/null @@ -1,50 +0,0 @@ -// src/routes/user.routes.js -// 사용자 라우트 - -const express = require('express'); -const { body } = require('express-validator'); -const userController = require('../controllers/user.controller'); -const { authenticateJWT } = require('../middlewares/auth.middleware'); -const { validateRequest } = require('../middlewares/validation.middleware'); - -const router = express.Router(); - -// 모든 라우트에 JWT 인증 적용 -router.use(authenticateJWT); - -/** - * GET /api/v1/users/me - * 내 정보 조회 - */ -router.get('/me', userController.getMe); - -/** - * PATCH /api/v1/users/me - * 내 정보 수정 - */ -router.patch( - '/me', - [ - body('name') - .optional() - .trim() - .isLength({ min: 2, max: 100 }) - .withMessage('이름은 2-100자 사이여야 합니다'), - body('password') - .optional() - .isLength({ min: 8 }) - .withMessage('비밀번호는 최소 8자 이상이어야 합니다') - .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) - .withMessage('비밀번호는 대문자, 소문자, 숫자를 포함해야 합니다'), - validateRequest, - ], - userController.updateMe -); - -/** - * DELETE /api/v1/users/me - * 계정 삭제 - */ -router.delete('/me', userController.deleteMe); - -module.exports = router; diff --git a/ai-assistant/src/seeders/001-llm-providers.js b/ai-assistant/src/seeders/001-llm-providers.js deleted file mode 100644 index 02b95d9f..00000000 --- a/ai-assistant/src/seeders/001-llm-providers.js +++ /dev/null @@ -1,74 +0,0 @@ -// src/seeders/001-llm-providers.js -// LLM 프로바이더 시드 데이터 - -const { v4: uuidv4 } = require('uuid'); - -module.exports = { - up: async (queryInterface, Sequelize) => { - const now = new Date(); - - await queryInterface.bulkInsert('llm_providers', [ - { - id: uuidv4(), - name: 'gemini', - display_name: 'Google Gemini', - endpoint: null, // SDK 사용 - api_key: process.env.GEMINI_API_KEY || '', - model_name: 'gemini-2.0-flash', - priority: 1, - max_tokens: 8192, - temperature: 0.7, - timeout_ms: 60000, - cost_per_1k_input_tokens: 0.00025, - cost_per_1k_output_tokens: 0.001, - is_active: true, - is_healthy: true, - config: JSON.stringify({}), - created_at: now, - updated_at: now, - }, - { - id: uuidv4(), - name: 'openai', - display_name: 'OpenAI GPT', - endpoint: 'https://api.openai.com/v1/chat/completions', - api_key: process.env.OPENAI_API_KEY || '', - model_name: 'gpt-4o-mini', - priority: 2, - max_tokens: 4096, - temperature: 0.7, - timeout_ms: 60000, - cost_per_1k_input_tokens: 0.00015, - cost_per_1k_output_tokens: 0.0006, - is_active: true, - is_healthy: true, - config: JSON.stringify({}), - created_at: now, - updated_at: now, - }, - { - id: uuidv4(), - name: 'claude', - display_name: 'Anthropic Claude', - endpoint: 'https://api.anthropic.com/v1/messages', - api_key: process.env.CLAUDE_API_KEY || '', - model_name: 'claude-3-haiku-20240307', - priority: 3, - max_tokens: 4096, - temperature: 0.7, - timeout_ms: 60000, - cost_per_1k_input_tokens: 0.00025, - cost_per_1k_output_tokens: 0.00125, - is_active: true, - is_healthy: true, - config: JSON.stringify({}), - created_at: now, - updated_at: now, - }, - ]); - }, - - down: async (queryInterface, Sequelize) => { - await queryInterface.bulkDelete('llm_providers', null, {}); - }, -}; diff --git a/ai-assistant/src/services/init.service.js b/ai-assistant/src/services/init.service.js deleted file mode 100644 index 65993d3d..00000000 --- a/ai-assistant/src/services/init.service.js +++ /dev/null @@ -1,128 +0,0 @@ -// src/services/init.service.js -// 초기 데이터 설정 서비스 - -const { User, LLMProvider } = require('../models'); -const logger = require('../config/logger.config'); - -/** - * 초기 관리자 계정 생성 - */ -async function createDefaultAdmin() { - try { - const adminEmail = process.env.ADMIN_EMAIL || 'admin@admin.com'; - const adminPassword = process.env.ADMIN_PASSWORD || 'Admin123!'; - - const existing = await User.findOne({ where: { email: adminEmail } }); - if (existing) { - logger.info(`관리자 계정 이미 존재: ${adminEmail}`); - return existing; - } - - const admin = await User.create({ - email: adminEmail, - password: adminPassword, - name: '관리자', - role: 'admin', - status: 'active', - plan: 'enterprise', - monthlyTokenLimit: 10000000, // 1000만 토큰 - }); - - logger.info(`✅ 기본 관리자 계정 생성: ${adminEmail}`); - return admin; - } catch (error) { - logger.error('관리자 계정 생성 실패:', error); - throw error; - } -} - -/** - * 기본 LLM 프로바이더 생성 - */ -async function createDefaultProviders() { - try { - const providers = [ - { - name: 'gemini', - displayName: 'Google Gemini', - endpoint: null, - apiKey: process.env.GEMINI_API_KEY || '', - modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash', - priority: 1, - maxTokens: 8192, - temperature: 0.7, - timeoutMs: 60000, - costPer1kInputTokens: 0.00025, - costPer1kOutputTokens: 0.001, - }, - { - name: 'openai', - displayName: 'OpenAI GPT', - endpoint: 'https://api.openai.com/v1/chat/completions', - apiKey: process.env.OPENAI_API_KEY || '', - modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini', - priority: 2, - maxTokens: 4096, - temperature: 0.7, - timeoutMs: 60000, - costPer1kInputTokens: 0.00015, - costPer1kOutputTokens: 0.0006, - }, - { - name: 'claude', - displayName: 'Anthropic Claude', - endpoint: 'https://api.anthropic.com/v1/messages', - apiKey: process.env.CLAUDE_API_KEY || '', - modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307', - priority: 3, - maxTokens: 4096, - temperature: 0.7, - timeoutMs: 60000, - costPer1kInputTokens: 0.00025, - costPer1kOutputTokens: 0.00125, - }, - ]; - - for (const providerData of providers) { - const existing = await LLMProvider.findOne({ where: { name: providerData.name } }); - if (existing) { - // API 키가 환경변수에 설정되어 있고 DB에는 없으면 업데이트 - if (providerData.apiKey && !existing.apiKey) { - existing.apiKey = providerData.apiKey; - existing.modelName = providerData.modelName; - await existing.save(); - logger.info(`LLM 프로바이더 API 키 업데이트: ${providerData.name}`); - } - continue; - } - - await LLMProvider.create({ - ...providerData, - isActive: true, - isHealthy: !!providerData.apiKey, // API 키가 있으면 healthy - }); - logger.info(`✅ LLM 프로바이더 생성: ${providerData.name} (${providerData.modelName})`); - } - } catch (error) { - logger.error('LLM 프로바이더 생성 실패:', error); - throw error; - } -} - -/** - * 초기화 실행 - */ -async function initialize() { - logger.info('🔧 초기 데이터 설정 시작...'); - - await createDefaultAdmin(); - await createDefaultProviders(); - - logger.info('✅ 초기 데이터 설정 완료'); -} - -module.exports = { - initialize, - createDefaultAdmin, - createDefaultProviders, -}; diff --git a/ai-assistant/src/services/llm.service.js b/ai-assistant/src/services/llm.service.js deleted file mode 100644 index fd17f16d..00000000 --- a/ai-assistant/src/services/llm.service.js +++ /dev/null @@ -1,385 +0,0 @@ -// src/services/llm.service.js -// LLM 서비스 - 멀티 프로바이더 지원 - -const axios = require('axios'); -const { LLMProvider } = require('../models'); -const logger = require('../config/logger.config'); - -class LLMService { - constructor() { - this.providers = []; - this.initialized = false; - } - - /** - * 서비스 초기화 - */ - async initialize() { - if (this.initialized) return; - - try { - await this.loadProviders(); - this.initialized = true; - logger.info('✅ LLM 서비스 초기화 완료'); - } catch (error) { - logger.error('❌ LLM 서비스 초기화 실패:', error); - // 초기화 실패 시 기본 프로바이더 사용 - this.providers = this.getDefaultProviders(); - this.initialized = true; - } - } - - /** - * 데이터베이스에서 프로바이더 로드 - */ - async loadProviders() { - try { - const providers = await LLMProvider.getHealthyProviders(); - - if (providers.length === 0) { - logger.warn('⚠️ 활성 프로바이더가 없습니다. 기본 프로바이더 사용'); - this.providers = this.getDefaultProviders(); - } else { - this.providers = providers.map((p) => ({ - id: p.id, - name: p.name, - endpoint: p.endpoint, - apiKey: p.apiKey, - modelName: p.modelName, - priority: p.priority, - maxTokens: p.maxTokens, - temperature: p.temperature, - timeoutMs: p.timeoutMs, - costPer1kInputTokens: parseFloat(p.costPer1kInputTokens) || 0, - costPer1kOutputTokens: parseFloat(p.costPer1kOutputTokens) || 0, - isHealthy: p.isHealthy, - config: p.config, - })); - } - - logger.info(`📥 ${this.providers.length}개 프로바이더 로드됨`); - } catch (error) { - logger.error('프로바이더 로드 실패:', error); - throw error; - } - } - - /** - * 기본 프로바이더 설정 (환경 변수 기반) - */ - getDefaultProviders() { - const providers = []; - - // Gemini - if (process.env.GEMINI_API_KEY) { - providers.push({ - id: 'default-gemini', - name: 'gemini', - apiKey: process.env.GEMINI_API_KEY, - modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash', - priority: 1, - maxTokens: 8192, - temperature: 0.7, - timeoutMs: 60000, - costPer1kInputTokens: 0.00025, - costPer1kOutputTokens: 0.001, - isHealthy: true, - }); - } - - // OpenAI - if (process.env.OPENAI_API_KEY) { - providers.push({ - id: 'default-openai', - name: 'openai', - endpoint: 'https://api.openai.com/v1/chat/completions', - apiKey: process.env.OPENAI_API_KEY, - modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini', - priority: 2, - maxTokens: 4096, - temperature: 0.7, - timeoutMs: 60000, - costPer1kInputTokens: 0.00015, - costPer1kOutputTokens: 0.0006, - isHealthy: true, - }); - } - - // Claude - if (process.env.CLAUDE_API_KEY) { - providers.push({ - id: 'default-claude', - name: 'claude', - endpoint: 'https://api.anthropic.com/v1/messages', - apiKey: process.env.CLAUDE_API_KEY, - modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307', - priority: 3, - maxTokens: 4096, - temperature: 0.7, - timeoutMs: 60000, - costPer1kInputTokens: 0.00025, - costPer1kOutputTokens: 0.00125, - isHealthy: true, - }); - } - - return providers; - } - - /** - * 채팅 API 호출 (자동 fallback) - */ - async chat(params) { - const { - model, - messages, - temperature = 0.7, - maxTokens = 4096, - userId, - apiKeyId, - } = params; - - // 초기화 확인 - if (!this.initialized) { - await this.initialize(); - } - - const startTime = Date.now(); - let lastError = null; - - // 요청된 모델에 맞는 프로바이더 찾기 - const requestedProvider = this.providers.find( - (p) => p.modelName === model || p.name === model - ); - - // 우선순위 순으로 프로바이더 정렬 - const sortedProviders = requestedProvider - ? [requestedProvider, ...this.providers.filter((p) => p !== requestedProvider)] - : this.providers; - - // 프로바이더 순회 (fallback) - for (const provider of sortedProviders) { - if (!provider.isHealthy) { - logger.warn(`⚠️ ${provider.name} 건강하지 않음, 건너뜀`); - continue; - } - - try { - logger.info(`🚀 ${provider.name} (${provider.modelName}) 시도 중...`); - - const result = await this.callProvider(provider, { - messages, - maxTokens: maxTokens || provider.maxTokens, - temperature: temperature || provider.temperature, - }); - - const responseTime = Date.now() - startTime; - - // 비용 계산 - const cost = this.calculateCost( - result.usage.promptTokens, - result.usage.completionTokens, - provider.costPer1kInputTokens, - provider.costPer1kOutputTokens - ); - - logger.info( - `✅ ${provider.name} 성공 (${responseTime}ms, ${result.usage.totalTokens} tokens)` - ); - - return { - text: result.text, - provider: provider.name, - providerId: provider.id, - model: provider.modelName, - usage: result.usage, - responseTime, - cost, - }; - } catch (error) { - logger.error(`❌ ${provider.name} 실패:`, error.message); - lastError = error; - - // 다음 프로바이더로 fallback - continue; - } - } - - // 모든 프로바이더 실패 - throw new Error( - `모든 LLM 프로바이더가 실패했습니다: ${lastError?.message || '알 수 없는 오류'}` - ); - } - - /** - * 개별 프로바이더 호출 - */ - async callProvider(provider, { messages, maxTokens, temperature }) { - const timeout = provider.timeoutMs || 60000; - - switch (provider.name) { - case 'gemini': - return this.callGemini(provider, { messages, maxTokens, temperature }); - case 'openai': - return this.callOpenAI(provider, { messages, maxTokens, temperature, timeout }); - case 'claude': - return this.callClaude(provider, { messages, maxTokens, temperature, timeout }); - default: - throw new Error(`지원하지 않는 프로바이더: ${provider.name}`); - } - } - - /** - * Gemini API 호출 - */ - async callGemini(provider, { messages, maxTokens, temperature }) { - const { GoogleGenAI } = require('@google/genai'); - - const ai = new GoogleGenAI({ apiKey: provider.apiKey }); - - // 메시지 변환 (OpenAI 형식 -> Gemini 형식) - const contents = messages.map((msg) => ({ - role: msg.role === 'assistant' ? 'model' : 'user', - parts: [{ text: msg.content }], - })); - - // system 메시지 처리 - const systemMessage = messages.find((m) => m.role === 'system'); - const systemInstruction = systemMessage ? systemMessage.content : undefined; - - const config = { - maxOutputTokens: maxTokens, - temperature, - }; - - const result = await ai.models.generateContent({ - model: provider.modelName, - contents: contents.filter((c) => c.role !== 'system'), - systemInstruction, - config, - }); - - // 응답 텍스트 추출 - let text = ''; - if (result.candidates?.[0]?.content?.parts) { - text = result.candidates[0].content.parts - .filter((p) => p.text) - .map((p) => p.text) - .join('\n'); - } - - const usage = result.usageMetadata || {}; - const promptTokens = usage.promptTokenCount ?? 0; - const completionTokens = usage.candidatesTokenCount ?? 0; - - return { - text, - usage: { - promptTokens, - completionTokens, - totalTokens: promptTokens + completionTokens, - }, - }; - } - - /** - * OpenAI API 호출 - */ - async callOpenAI(provider, { messages, maxTokens, temperature, timeout }) { - const response = await axios.post( - provider.endpoint, - { - model: provider.modelName, - messages, - max_tokens: maxTokens, - temperature, - }, - { - timeout, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${provider.apiKey}`, - }, - } - ); - - return { - text: response.data.choices[0].message.content, - usage: { - promptTokens: response.data.usage.prompt_tokens, - completionTokens: response.data.usage.completion_tokens, - totalTokens: response.data.usage.total_tokens, - }, - }; - } - - /** - * Claude API 호출 - */ - async callClaude(provider, { messages, maxTokens, temperature, timeout }) { - // system 메시지 분리 - const systemMessage = messages.find((m) => m.role === 'system'); - const otherMessages = messages.filter((m) => m.role !== 'system'); - - const response = await axios.post( - provider.endpoint, - { - model: provider.modelName, - messages: otherMessages, - system: systemMessage?.content, - max_tokens: maxTokens, - temperature, - }, - { - timeout, - headers: { - 'Content-Type': 'application/json', - 'x-api-key': provider.apiKey, - 'anthropic-version': '2023-06-01', - }, - } - ); - - return { - text: response.data.content[0].text, - usage: { - promptTokens: response.data.usage.input_tokens, - completionTokens: response.data.usage.output_tokens, - totalTokens: - response.data.usage.input_tokens + response.data.usage.output_tokens, - }, - }; - } - - /** - * 스트리밍 채팅 (제너레이터) - */ - async *chatStream(params) { - // 현재는 간단한 구현 (전체 응답 후 청크로 분할) - // 실제 스트리밍은 각 프로바이더의 스트리밍 API 사용 필요 - const result = await this.chat(params); - - // 텍스트를 청크로 분할하여 전송 - const chunkSize = 10; - for (let i = 0; i < result.text.length; i += chunkSize) { - yield { - text: result.text.slice(i, i + chunkSize), - done: i + chunkSize >= result.text.length, - }; - } - } - - /** - * 비용 계산 - */ - calculateCost(promptTokens, completionTokens, inputCost, outputCost) { - const inputTotal = (promptTokens / 1000) * inputCost; - const outputTotal = (completionTokens / 1000) * outputCost; - return parseFloat((inputTotal + outputTotal).toFixed(6)); - } -} - -// 싱글톤 인스턴스 -const llmService = new LLMService(); - -module.exports = llmService; diff --git a/ai-assistant/src/swagger/api-docs.js b/ai-assistant/src/swagger/api-docs.js deleted file mode 100644 index 6518aaca..00000000 --- a/ai-assistant/src/swagger/api-docs.js +++ /dev/null @@ -1,359 +0,0 @@ -// src/swagger/api-docs.js -// Swagger API 문서 정의 - -/** - * @swagger - * /auth/register: - * post: - * tags: [Auth] - * summary: 회원가입 - * description: 새 계정을 생성합니다. - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [email, password, name] - * properties: - * email: - * type: string - * format: email - * example: user@example.com - * password: - * type: string - * minLength: 8 - * example: Password123! - * description: 8자 이상, 영문/숫자/특수문자 포함 - * name: - * type: string - * example: 홍길동 - * responses: - * 201: - * description: 회원가입 성공 - * 400: - * description: 유효성 검사 실패 - * 409: - * description: 이미 존재하는 이메일 - */ - -/** - * @swagger - * /auth/login: - * post: - * tags: [Auth] - * summary: 로그인 - * description: 이메일과 비밀번호로 로그인합니다. - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [email, password] - * properties: - * email: - * type: string - * format: email - * example: admin@admin.com - * password: - * type: string - * example: Admin123! - * responses: - * 200: - * description: 로그인 성공 - * content: - * application/json: - * schema: - * type: object - * properties: - * success: - * type: boolean - * example: true - * data: - * type: object - * properties: - * user: - * type: object - * accessToken: - * type: string - * description: JWT 액세스 토큰 - * refreshToken: - * type: string - * description: JWT 리프레시 토큰 - * 401: - * description: 인증 실패 - */ - -/** - * @swagger - * /chat/completions: - * post: - * tags: [Chat] - * summary: 채팅 완성 (OpenAI 호환) - * description: | - * AI 모델에 메시지를 보내고 응답을 받습니다. - * OpenAI API와 호환되는 형식입니다. - * - * **인증**: JWT 토큰 또는 API 키 (sk-xxx) - * security: - * - BearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ChatCompletionRequest' - * examples: - * simple: - * summary: 간단한 질문 - * value: - * model: gemini-2.0-flash - * messages: - * - role: user - * content: 안녕하세요! - * with_system: - * summary: 시스템 프롬프트 포함 - * value: - * model: gemini-2.0-flash - * messages: - * - role: system - * content: 당신은 친절한 AI 어시스턴트입니다. - * - role: user - * content: 파이썬으로 Hello World 출력하는 코드 알려줘 - * temperature: 0.7 - * max_tokens: 1000 - * responses: - * 200: - * description: 성공 - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/ChatCompletionResponse' - * 401: - * description: 인증 실패 - * 429: - * description: 요청 한도 초과 - */ - -/** - * @swagger - * /models: - * get: - * tags: [Models] - * summary: 모델 목록 조회 - * description: 사용 가능한 AI 모델 목록을 조회합니다. - * security: - * - BearerAuth: [] - * responses: - * 200: - * description: 성공 - * content: - * application/json: - * schema: - * type: object - * properties: - * object: - * type: string - * example: list - * data: - * type: array - * items: - * type: object - * properties: - * id: - * type: string - * example: gemini-2.0-flash - * object: - * type: string - * example: model - * owned_by: - * type: string - * example: google - */ - -/** - * @swagger - * /api-keys: - * get: - * tags: [API Keys] - * summary: API 키 목록 조회 - * description: 발급받은 API 키 목록을 조회합니다. - * security: - * - BearerAuth: [] - * responses: - * 200: - * description: 성공 - * post: - * tags: [API Keys] - * summary: API 키 발급 - * description: 새 API 키를 발급받습니다. 키는 한 번만 표시됩니다. - * security: - * - BearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [name] - * properties: - * name: - * type: string - * example: My API Key - * description: API 키 이름 - * responses: - * 201: - * description: 발급 성공 - * content: - * application/json: - * schema: - * type: object - * properties: - * success: - * type: boolean - * data: - * type: object - * properties: - * key: - * type: string - * description: 발급된 API 키 (한 번만 표시) - * example: sk-abc123def456... - */ - -/** - * @swagger - * /api-keys/{id}: - * delete: - * tags: [API Keys] - * summary: API 키 폐기 - * description: API 키를 폐기합니다. - * security: - * - BearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * description: API 키 ID - * responses: - * 200: - * description: 폐기 성공 - * 404: - * description: API 키를 찾을 수 없음 - */ - -/** - * @swagger - * /usage: - * get: - * tags: [Usage] - * summary: 사용량 요약 조회 - * description: 오늘/이번 달 사용량 요약을 조회합니다. - * security: - * - BearerAuth: [] - * responses: - * 200: - * description: 성공 - * content: - * application/json: - * schema: - * type: object - * properties: - * success: - * type: boolean - * data: - * type: object - * properties: - * plan: - * type: string - * example: free - * limit: - * type: object - * properties: - * monthly: - * type: integer - * remaining: - * type: integer - * usage: - * type: object - * properties: - * today: - * type: object - * monthly: - * type: object - */ - -/** - * @swagger - * /usage/logs: - * get: - * tags: [Usage] - * summary: 사용 로그 조회 - * description: API 호출 로그를 조회합니다. - * security: - * - BearerAuth: [] - * parameters: - * - in: query - * name: page - * schema: - * type: integer - * default: 1 - * - in: query - * name: limit - * schema: - * type: integer - * default: 20 - * responses: - * 200: - * description: 성공 - */ - -/** - * @swagger - * /admin/users: - * get: - * tags: [Admin] - * summary: 사용자 목록 조회 (관리자) - * description: 모든 사용자 목록을 조회합니다. 관리자 권한 필요. - * security: - * - BearerAuth: [] - * responses: - * 200: - * description: 성공 - * 403: - * description: 권한 없음 - */ - -/** - * @swagger - * /admin/providers: - * get: - * tags: [Admin] - * summary: LLM 프로바이더 목록 (관리자) - * description: LLM 프로바이더 설정을 조회합니다. 관리자 권한 필요. - * security: - * - BearerAuth: [] - * responses: - * 200: - * description: 성공 - * 403: - * description: 권한 없음 - */ - -/** - * @swagger - * /admin/stats: - * get: - * tags: [Admin] - * summary: 시스템 통계 (관리자) - * description: 시스템 전체 통계를 조회합니다. 관리자 권한 필요. - * security: - * - BearerAuth: [] - * responses: - * 200: - * description: 성공 - * 403: - * description: 권한 없음 - */ diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index ff14ba19..e7a538cd 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -92,6 +92,7 @@ import screenFileRoutes from "./routes/screenFileRoutes"; //import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; import batchRoutes from "./routes/batchRoutes"; import batchManagementRoutes from "./routes/batchManagementRoutes"; +import knowledgeRoutes from "./routes/knowledgeRoutes"; import batchExecutionLogRoutes from "./routes/batchExecutionLogRoutes"; // import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; // 파일이 존재하지 않음 import ddlRoutes from "./routes/ddlRoutes"; @@ -150,6 +151,11 @@ import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes" import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트) import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준 import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스) +import aiAgentRoutes from "./routes/aiAgentRoutes"; // AI 에이전트 관리 +import aiAgentGroupRoutes from "./routes/aiAgentGroupRoutes"; // 멀티 에이전트 워크스페이스 +import aiScheduleRoutes from "./routes/aiScheduleRoutes"; // AI 스케줄러 +import aiProxyRoutes from "./routes/openClawProxyRoutes"; // AI 엔진 (자체 LLM 클라이언트) +import pipelineDeviceConnectionRoutes from "./routes/pipelineDeviceConnectionRoutes"; // 파이프라인 장비 연결 import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 import moldRoutes from "./routes/moldRoutes"; // 금형 관리 import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리 @@ -323,7 +329,7 @@ app.use("/api/external-db-connections", externalDbConnectionRoutes); app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes); app.use("/api/multi-connection", multiConnectionRoutes); app.use("/api/screen-files", screenFileRoutes); -app.use("/api/batch-configs", batchRoutes); +// app.use("/api/batch-configs", batchRoutes); // 레거시 → batchManagementRoutes로 통합 app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿 app.use("/api/batch-management", batchManagementRoutes); app.use("/api/batch-execution-logs", batchExecutionLogRoutes); @@ -386,7 +392,13 @@ app.use("/api/receiving", receivingRoutes); // 입고관리 app.use("/api/outbound", outboundRoutes); // 출고관리 app.use("/api/quotes", quoteRoutes); // 견적관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 -app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) +// app.use("/api/ai/v1", aiAssistantProxy); // 레거시 AI 어시스턴트 (비활성) +app.use("/api/ai-agents", aiAgentRoutes); // AI 에이전트 관리 +app.use("/api/ai-agent-groups", aiAgentGroupRoutes); // 멀티 에이전트 워크스페이스 +app.use("/api/ai-knowledge", knowledgeRoutes); // AI 지식 파일 라이브러리 +app.use("/api/ai-schedules", aiScheduleRoutes); // AI 스케줄러 +app.use("/api/ai/v1", aiProxyRoutes); // AI 엔진 (자체 LLM 클라이언트) +app.use("/api/pipeline-device-connections", pipelineDeviceConnectionRoutes); // 파이프라인 장비 연결 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 app.use("/api/approval", approvalRoutes); // 결재 시스템 app.use("/api/user-mail", userMailRoutes); // 사용자 메일 계정 @@ -465,6 +477,11 @@ async function initializeServices() { runMessengerMigration, runSmartFactoryLogMigration, runSmartFactoryScheduleMigration, + runOpenClawMigration, + runMultiAgentMigration, + runMenuRenameMigration, + runKnowledgeLibraryMigration, + runPipelineDeviceMigration, } = await import("./database/runMigration"); await runDashboardMigration(); @@ -475,6 +492,11 @@ async function initializeServices() { await runMessengerMigration(); await runSmartFactoryLogMigration(); await runSmartFactoryScheduleMigration(); + await runOpenClawMigration(); + await runMultiAgentMigration(); + await runMenuRenameMigration(); + await runKnowledgeLibraryMigration(); + await runPipelineDeviceMigration(); } catch (error) { logger.error(`❌ 마이그레이션 실패:`, error); } @@ -539,6 +561,15 @@ async function initializeServices() { } catch (error) { logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error); } + + // AI 스케줄러 초기화 + try { + const { AiSchedulerService } = await import("./services/aiSchedulerService"); + await AiSchedulerService.initializeSchedules(); + logger.info("🤖 AI 엔진 초기화 완료 (자체 LLM 클라이언트)"); + } catch (error) { + logger.warn("⚠️ AI 스케줄러 초기화 스킵:", error); + } } export default app; diff --git a/backend-node/src/controllers/aiAgentController.ts b/backend-node/src/controllers/aiAgentController.ts new file mode 100644 index 00000000..bcc12ac4 --- /dev/null +++ b/backend-node/src/controllers/aiAgentController.ts @@ -0,0 +1,138 @@ +import { Request, Response } from "express"; +import { AiAgentService } from "../services/aiAgentService"; +import { AiAgentApiKeyService } from "../services/aiAgentApiKeyService"; +import { AiAgentProviderService } from "../services/aiAgentProviderService"; +import { AiAgentConversationService } from "../services/aiAgentConversationService"; +import { AiAgentUsageService } from "../services/aiAgentUsageService"; +import { AuthenticatedRequest } from "../types/auth"; +import { logger } from "../utils/logger"; + +// ========== 에이전트 CRUD ========== +export class AiAgentController { + static async list(req: AuthenticatedRequest, res: Response) { + const { status, company_code, search } = req.query; + // company_code=* 이면 전체 조회, 아니면 해당 회사만 + const isSuperAdmin = req.user?.userType === "SUPER_ADMIN"; + const companyFilter = (company_code as string) === "*" || isSuperAdmin + ? undefined + : (company_code as string) || req.user?.companyCode; + const agents = await AiAgentService.list({ + status: status as string, + company_code: companyFilter, + search: search as string, + }); + res.json({ success: true, data: agents }); + } + + static async getById(req: AuthenticatedRequest, res: Response) { + const agent = await AiAgentService.getById(parseInt(req.params.id)); + if (!agent) return res.status(404).json({ success: false, message: "에이전트를 찾을 수 없습니다." }); + res.json({ success: true, data: agent }); + } + + static async create(req: AuthenticatedRequest, res: Response) { + const agent = await AiAgentService.create(req.body, req.user!.userId); + res.status(201).json({ success: true, data: agent, message: "에이전트가 생성되었습니다." }); + } + + static async update(req: AuthenticatedRequest, res: Response) { + const agent = await AiAgentService.update(parseInt(req.params.id), req.body); + if (!agent) return res.status(404).json({ success: false, message: "에이전트를 찾을 수 없습니다." }); + res.json({ success: true, data: agent, message: "에이전트가 수정되었습니다." }); + } + + static async delete(req: AuthenticatedRequest, res: Response) { + await AiAgentService.delete(parseInt(req.params.id)); + res.json({ success: true, message: "에이전트가 삭제되었습니다." }); + } +} + +// ========== API 키 관리 ========== +export class AiAgentApiKeyController { + static async list(req: AuthenticatedRequest, res: Response) { + const isAdmin = req.user?.userType === "SUPER_ADMIN" || req.user?.userType === "COMPANY_ADMIN"; + const keys = await AiAgentApiKeyService.list(req.user!.userId, isAdmin); + res.json({ success: true, data: keys }); + } + + static async create(req: AuthenticatedRequest, res: Response) { + const { key, plainKey } = await AiAgentApiKeyService.create(req.body, req.user!.userId, req.user?.companyCode); + res.status(201).json({ + success: true, + data: { ...key, plain_key: plainKey }, + message: "API 키가 생성되었습니다. 키는 한 번만 표시됩니다.", + }); + } + + static async revoke(req: AuthenticatedRequest, res: Response) { + await AiAgentApiKeyService.revoke(parseInt(req.params.id), req.user!.userId); + res.json({ success: true, message: "API 키가 폐기되었습니다." }); + } +} + +// ========== LLM 프로바이더 관리 ========== +export class AiAgentProviderController { + static async list(req: Request, res: Response) { + const providers = await AiAgentProviderService.list(); + res.json({ success: true, data: providers }); + } + + static async create(req: Request, res: Response) { + const provider = await AiAgentProviderService.create(req.body); + res.status(201).json({ success: true, data: provider, message: "프로바이더가 추가되었습니다." }); + } + + static async update(req: Request, res: Response) { + const provider = await AiAgentProviderService.update(parseInt(req.params.id), req.body); + if (!provider) return res.status(404).json({ success: false, message: "프로바이더를 찾을 수 없습니다." }); + res.json({ success: true, data: provider, message: "프로바이더가 수정되었습니다." }); + } + + static async delete(req: Request, res: Response) { + await AiAgentProviderService.delete(parseInt(req.params.id)); + res.json({ success: true, message: "프로바이더가 삭제되었습니다." }); + } +} + +// ========== 대화 모니터링 ========== +export class AiAgentConversationController { + static async list(req: Request, res: Response) { + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + const agentId = req.query.agent_id ? parseInt(req.query.agent_id as string) : undefined; + const result = await AiAgentConversationService.list(page, limit, agentId); + res.json({ success: true, data: result.conversations, total: result.total }); + } + + static async getById(req: Request, res: Response) { + const result = await AiAgentConversationService.getById(parseInt(req.params.id)); + if (!result.conversation) return res.status(404).json({ success: false, message: "대화를 찾을 수 없습니다." }); + res.json({ success: true, data: result }); + } + + static async delete(req: Request, res: Response) { + await AiAgentConversationService.delete(parseInt(req.params.id)); + res.json({ success: true, message: "대화가 삭제되었습니다." }); + } +} + +// ========== 사용량 ========== +export class AiAgentUsageController { + static async summary(req: Request, res: Response) { + const summary = await AiAgentUsageService.getSummary(); + res.json({ success: true, data: summary }); + } + + static async logs(req: Request, res: Response) { + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + const result = await AiAgentUsageService.getLogs(page, limit); + res.json({ success: true, data: result.logs, total: result.total }); + } + + static async daily(req: Request, res: Response) { + const days = parseInt(req.query.days as string) || 30; + const data = await AiAgentUsageService.getDailyUsage(days); + res.json({ success: true, data }); + } +} diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index 0845b1cb..4e5987a8 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -420,6 +420,26 @@ export class BatchManagementController { } } + /** + * 배치 설정 삭제 + */ + static async deleteBatchConfig(req: AuthenticatedRequest, res: Response) { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ success: false, message: "유효하지 않은 배치 ID입니다." }); + } + const result = await BatchService.deleteBatchConfig( + id, + req.user?.userId, + req.user?.companyCode + ); + res.json(result); + } catch (error: any) { + res.status(500).json({ success: false, message: error.message || "배치 삭제에 실패했습니다." }); + } + } + /** * REST API 데이터 미리보기 */ diff --git a/backend-node/src/database/runMigration.ts b/backend-node/src/database/runMigration.ts index 85f818a5..db380ef7 100644 --- a/backend-node/src/database/runMigration.ts +++ b/backend-node/src/database/runMigration.ts @@ -257,3 +257,91 @@ export async function runDtgManagementLogMigration() { } } } + +/** + * OpenClaw 멀티 에이전트 AI 통합 테이블 마이그레이션 + * ai_agents, ai_agent_api_keys, ai_agent_conversations, + * ai_agent_messages, ai_agent_usage_logs, ai_llm_providers + */ +export async function runMultiAgentMigration() { + try { + console.log("🔄 멀티 에이전트 워크스페이스 마이그레이션 시작..."); + const sqlFilePath = path.join(__dirname, "../../db/migrations/301_create_multi_agent_tables.sql"); + if (!fs.existsSync(sqlFilePath)) { console.log("⚠️ 멀티 에이전트 마이그레이션 파일 없음"); return; } + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + await PostgreSQLService.query(sqlContent); + console.log("✅ 멀티 에이전트 워크스페이스 마이그레이션 완료!"); + } catch (error) { + console.error("❌ 멀티 에이전트 마이그레이션 실패:", error); + if (error instanceof Error && error.message.includes("already exists")) { + console.log("ℹ️ 멀티 에이전트 테이블이 이미 존재합니다."); + } + } +} + +export async function runMenuRenameMigration() { + try { + const sqlFilePath = path.join(__dirname, "../../db/migrations/302_rename_menu_datasource.sql"); + if (!fs.existsSync(sqlFilePath)) return; + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + await PostgreSQLService.query(sqlContent); + console.log("✅ 메뉴명 변경 마이그레이션 완료 (외부 커넥션 관리 → 데이터 소스)"); + } catch (error) { + // 이미 변경되었거나 menu_info 테이블 구조가 다르면 무시 + console.log("ℹ️ 메뉴명 마이그레이션 스킵"); + } +} + +export async function runKnowledgeLibraryMigration() { + try { + const sqlFilePath = path.join(__dirname, "../../db/migrations/303_create_knowledge_library.sql"); + if (!fs.existsSync(sqlFilePath)) return; + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + await PostgreSQLService.query(sqlContent); + console.log("✅ 지식 파일 라이브러리 테이블 생성 완료"); + } catch (error) { + if (error instanceof Error && error.message.includes("already exists")) { + console.log("ℹ️ 지식 파일 테이블 이미 존재"); + } + } +} + +export async function runPipelineDeviceMigration() { + try { + const sqlFilePath = path.join(__dirname, "../../db/migrations/304_create_pipeline_device_tables.sql"); + if (!fs.existsSync(sqlFilePath)) return; + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + await PostgreSQLService.query(sqlContent); + console.log("✅ 파이프라인 장비 연결 테이블 생성 완료"); + } catch (error) { + if (error instanceof Error && error.message.includes("already exists")) { + console.log("ℹ️ 파이프라인 장비 테이블 이미 존재"); + } + } +} + +export async function runOpenClawMigration() { + try { + console.log("🔄 OpenClaw AI 에이전트 마이그레이션 시작..."); + + const sqlFilePath = path.join( + __dirname, + "../../db/migrations/300_create_openclaw_tables.sql" + ); + + if (!fs.existsSync(sqlFilePath)) { + console.log("⚠️ OpenClaw 마이그레이션 파일이 없습니다:", sqlFilePath); + return; + } + + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + await PostgreSQLService.query(sqlContent); + + console.log("✅ OpenClaw AI 에이전트 마이그레이션 완료!"); + } catch (error) { + console.error("❌ OpenClaw 마이그레이션 실패:", error); + if (error instanceof Error && error.message.includes("already exists")) { + console.log("ℹ️ OpenClaw 테이블이 이미 존재합니다."); + } + } +} diff --git a/backend-node/src/middleware/aiApiKeyAuthMiddleware.ts b/backend-node/src/middleware/aiApiKeyAuthMiddleware.ts new file mode 100644 index 00000000..79854f7d --- /dev/null +++ b/backend-node/src/middleware/aiApiKeyAuthMiddleware.ts @@ -0,0 +1,57 @@ +import { Request, Response, NextFunction } from "express"; +import { AiAgentApiKeyService } from "../services/aiAgentApiKeyService"; +import { AiAgentApiKey } from "../types/aiAgent"; +import { logger } from "../utils/logger"; + +export interface AiApiKeyRequest extends Request { + apiKey?: AiAgentApiKey; + apiKeyUserId?: string; +} + +/** + * 외부 서비스 API 키 인증 미들웨어 + * sk-pipe-xxx 형태의 키를 검증하고 rate limit 체크 + */ +export const aiApiKeyAuth = async ( + req: AiApiKeyRequest, + res: Response, + next: NextFunction +): Promise => { + const authHeader = req.get("Authorization"); + const token = authHeader && authHeader.split(" ")[1]; + + if (!token) { + res.status(401).json({ error: { message: "API key required", type: "auth_error" } }); + return; + } + + // Pipeline API 키 (sk-pipe-*) + if (token.startsWith("sk-pipe-")) { + const apiKey = await AiAgentApiKeyService.validateKey(token); + if (!apiKey) { + res.status(401).json({ error: { message: "Invalid API key", type: "auth_error" } }); + return; + } + + // 월간 토큰 제한 체크 + if (apiKey.monthly_token_limit > 0 && apiKey.total_tokens >= apiKey.monthly_token_limit) { + res.status(429).json({ error: { message: "Monthly token limit exceeded", type: "rate_limit_error" } }); + return; + } + + req.apiKey = apiKey; + req.apiKeyUserId = apiKey.user_id; + next(); + return; + } + + // JWT 토큰도 허용 (Pipeline 내부 사용자) + try { + const { JwtUtils } = await import("../utils/jwtUtils"); + const userInfo = JwtUtils.verifyToken(token); + (req as any).user = userInfo; + next(); + } catch { + res.status(401).json({ error: { message: "Invalid authentication", type: "auth_error" } }); + } +}; diff --git a/backend-node/src/routes/aiAgentGroupRoutes.ts b/backend-node/src/routes/aiAgentGroupRoutes.ts new file mode 100644 index 00000000..5ca11971 --- /dev/null +++ b/backend-node/src/routes/aiAgentGroupRoutes.ts @@ -0,0 +1,57 @@ +import { Router, Response } from "express"; +import { authenticateToken, AuthenticatedRequest } from "../middleware/authMiddleware"; +import { AiAgentGroupService } from "../services/aiAgentGroupService"; + +const router = Router(); +router.use(authenticateToken); + +// 그룹 CRUD +router.get("/", async (req: AuthenticatedRequest, res: Response) => { + const groups = await AiAgentGroupService.list(req.user?.companyCode); + res.json({ success: true, data: groups }); +}); + +router.get("/connectors", async (req: AuthenticatedRequest, res: Response) => { + const connectors = await AiAgentGroupService.getAvailableConnectors(); + res.json({ success: true, data: connectors }); +}); + +router.get("/:id", async (req: AuthenticatedRequest, res: Response) => { + const group = await AiAgentGroupService.getById(parseInt(req.params.id)); + if (!group) return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." }); + res.json({ success: true, data: group }); +}); + +router.post("/", async (req: AuthenticatedRequest, res: Response) => { + const group = await AiAgentGroupService.create(req.body, req.user!.userId); + res.status(201).json({ success: true, data: group, message: "멀티 에이전트 그룹이 생성되었습니다." }); +}); + +router.put("/:id", async (req: AuthenticatedRequest, res: Response) => { + const group = await AiAgentGroupService.update(parseInt(req.params.id), req.body); + if (!group) return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." }); + res.json({ success: true, data: group, message: "그룹이 수정되었습니다." }); +}); + +router.delete("/:id", async (req: AuthenticatedRequest, res: Response) => { + await AiAgentGroupService.delete(parseInt(req.params.id)); + res.json({ success: true, message: "그룹이 삭제되었습니다." }); +}); + +// 멤버 관리 +router.post("/:id/members", async (req: AuthenticatedRequest, res: Response) => { + const member = await AiAgentGroupService.addMember(parseInt(req.params.id), req.body); + res.status(201).json({ success: true, data: member, message: "멤버가 추가되었습니다." }); +}); + +router.put("/members/:memberId", async (req: AuthenticatedRequest, res: Response) => { + const member = await AiAgentGroupService.updateMember(parseInt(req.params.memberId), req.body); + res.json({ success: true, data: member, message: "멤버가 수정되었습니다." }); +}); + +router.delete("/members/:memberId", async (req: AuthenticatedRequest, res: Response) => { + await AiAgentGroupService.removeMember(parseInt(req.params.memberId)); + res.json({ success: true, message: "멤버가 제거되었습니다." }); +}); + +export default router; diff --git a/backend-node/src/routes/aiAgentRoutes.ts b/backend-node/src/routes/aiAgentRoutes.ts new file mode 100644 index 00000000..c9185e93 --- /dev/null +++ b/backend-node/src/routes/aiAgentRoutes.ts @@ -0,0 +1,44 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + AiAgentController, + AiAgentApiKeyController, + AiAgentProviderController, + AiAgentConversationController, + AiAgentUsageController, +} from "../controllers/aiAgentController"; + +const router = Router(); + +// 모든 라우트 인증 필요 +router.use(authenticateToken); + +// ===== 에이전트 CRUD ===== +router.get("/", AiAgentController.list); +router.get("/:id", AiAgentController.getById); +router.post("/", AiAgentController.create); +router.put("/:id", AiAgentController.update); +router.delete("/:id", AiAgentController.delete); + +// ===== API 키 관리 ===== +router.get("/keys/list", AiAgentApiKeyController.list); +router.post("/keys", AiAgentApiKeyController.create); +router.delete("/keys/:id", AiAgentApiKeyController.revoke); + +// ===== LLM 프로바이더 관리 ===== +router.get("/providers/list", AiAgentProviderController.list); +router.post("/providers", AiAgentProviderController.create); +router.put("/providers/:id", AiAgentProviderController.update); +router.delete("/providers/:id", AiAgentProviderController.delete); + +// ===== 대화 모니터링 ===== +router.get("/conversations/list", AiAgentConversationController.list); +router.get("/conversations/:id", AiAgentConversationController.getById); +router.delete("/conversations/:id", AiAgentConversationController.delete); + +// ===== 사용량 ===== +router.get("/usage/summary", AiAgentUsageController.summary); +router.get("/usage/logs", AiAgentUsageController.logs); +router.get("/usage/daily", AiAgentUsageController.daily); + +export default router; diff --git a/backend-node/src/routes/aiScheduleRoutes.ts b/backend-node/src/routes/aiScheduleRoutes.ts new file mode 100644 index 00000000..6305588b --- /dev/null +++ b/backend-node/src/routes/aiScheduleRoutes.ts @@ -0,0 +1,28 @@ +import { Router, Response } from "express"; +import { authenticateToken, AuthenticatedRequest } from "../middleware/authMiddleware"; +import { AiSchedulerService } from "../services/aiSchedulerService"; + +const router = Router(); +router.use(authenticateToken); + +router.get("/", async (req: AuthenticatedRequest, res: Response) => { + const schedules = await AiSchedulerService.list(); + res.json({ success: true, data: schedules }); +}); + +router.post("/", async (req: AuthenticatedRequest, res: Response) => { + const schedule = await AiSchedulerService.create(req.body, req.user!.userId); + res.status(201).json({ success: true, data: schedule, message: "스케줄이 생성되었습니다." }); +}); + +router.put("/:id", async (req: AuthenticatedRequest, res: Response) => { + const schedule = await AiSchedulerService.update(parseInt(req.params.id), req.body); + res.json({ success: true, data: schedule, message: "스케줄이 수정되었습니다." }); +}); + +router.delete("/:id", async (req: AuthenticatedRequest, res: Response) => { + await AiSchedulerService.delete(parseInt(req.params.id)); + res.json({ success: true, message: "스케줄이 삭제되었습니다." }); +}); + +export default router; diff --git a/backend-node/src/routes/batchManagementRoutes.ts b/backend-node/src/routes/batchManagementRoutes.ts index 372113c0..d112c31e 100644 --- a/backend-node/src/routes/batchManagementRoutes.ts +++ b/backend-node/src/routes/batchManagementRoutes.ts @@ -86,6 +86,12 @@ router.get("/batch-configs/:id/recent-logs", authenticateToken, BatchManagementC */ router.put("/batch-configs/:id", authenticateToken, BatchManagementController.updateBatchConfig); +/** + * DELETE /api/batch-management/batch-configs/:id + * 배치 설정 삭제 + */ +router.delete("/batch-configs/:id", authenticateToken, BatchManagementController.deleteBatchConfig); + /** * POST /api/batch-management/batch-configs/:id/execute * 배치 수동 실행 diff --git a/backend-node/src/routes/knowledgeRoutes.ts b/backend-node/src/routes/knowledgeRoutes.ts new file mode 100644 index 00000000..89173b2f --- /dev/null +++ b/backend-node/src/routes/knowledgeRoutes.ts @@ -0,0 +1,110 @@ +import { Router, Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { query, queryOne } from "../database/db"; +import { logger } from "../utils/logger"; + +const router = Router(); + +/** + * GET /api/ai-knowledge + * 지식 파일 라이브러리 목록 조회 + */ +router.get("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + const { category, search } = req.query; + let sql = "SELECT id, name, file_name, category, description, file_size, company_code, created_by, created_at, updated_at FROM ai_knowledge_files WHERE 1=1"; + const params: any[] = []; + let idx = 1; + + if (category && category !== "all") { + sql += ` AND category = $${idx++}`; + params.push(category); + } + if (search) { + sql += ` AND (name ILIKE $${idx} OR description ILIKE $${idx} OR file_name ILIKE $${idx})`; + params.push(`%${search}%`); + idx++; + } + sql += " ORDER BY category, name"; + + const files = await query(sql, params); + res.json({ success: true, data: files }); +}); + +/** + * GET /api/ai-knowledge/:id + * 지식 파일 내용 포함 상세 조회 + */ +router.get("/:id", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + const file = await queryOne("SELECT * FROM ai_knowledge_files WHERE id = $1", [req.params.id]); + if (!file) return res.status(404).json({ success: false, message: "파일을 찾을 수 없습니다." }); + res.json({ success: true, data: file }); +}); + +/** + * POST /api/ai-knowledge + * 지식 파일 업로드 (라이브러리에 등록) + */ +router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + const { name, file_name, category, description, content } = req.body; + if (!name || !content || !category) { + return res.status(400).json({ success: false, message: "name, content, category는 필수입니다." }); + } + + const result = await query( + `INSERT INTO ai_knowledge_files (name, file_name, category, description, content, file_size, company_code, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, name, file_name, category, description, file_size, created_at`, + [name, file_name || name, category, description || null, content, Buffer.byteLength(content, "utf8"), req.user?.companyCode || null, req.user?.userId] + ); + + logger.info(`지식 파일 등록: ${name} (${category})`); + res.status(201).json({ success: true, data: result[0] }); +}); + +/** + * PUT /api/ai-knowledge/:id + * 지식 파일 수정 + */ +router.put("/:id", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + const { name, category, description, content } = req.body; + const sets: string[] = []; + const params: any[] = []; + let idx = 1; + + if (name !== undefined) { sets.push(`name = $${idx++}`); params.push(name); } + if (category !== undefined) { sets.push(`category = $${idx++}`); params.push(category); } + if (description !== undefined) { sets.push(`description = $${idx++}`); params.push(description); } + if (content !== undefined) { + sets.push(`content = $${idx++}`); params.push(content); + sets.push(`file_size = $${idx++}`); params.push(Buffer.byteLength(content, "utf8")); + } + if (sets.length === 0) return res.json({ success: true }); + + sets.push("updated_at = NOW()"); + params.push(req.params.id); + + await query(`UPDATE ai_knowledge_files SET ${sets.join(", ")} WHERE id = $${idx}`, params); + res.json({ success: true, message: "수정 완료" }); +}); + +/** + * DELETE /api/ai-knowledge/:id + * 지식 파일 삭제 + */ +router.delete("/:id", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + await query("DELETE FROM ai_knowledge_files WHERE id = $1", [req.params.id]); + res.json({ success: true, message: "삭제 완료" }); +}); + +/** + * GET /api/ai-knowledge/categories/list + * 카테고리 목록 (파일 수 포함) + */ +router.get("/categories/list", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + const cats = await query( + "SELECT category, COUNT(*) as count FROM ai_knowledge_files GROUP BY category ORDER BY category" + ); + res.json({ success: true, data: cats }); +}); + +export default router; diff --git a/backend-node/src/routes/openClawProxyRoutes.ts b/backend-node/src/routes/openClawProxyRoutes.ts new file mode 100644 index 00000000..0ee07c1c --- /dev/null +++ b/backend-node/src/routes/openClawProxyRoutes.ts @@ -0,0 +1,205 @@ +import { Router, Request, Response } from "express"; +import { aiApiKeyAuth, AiApiKeyRequest } from "../middleware/aiApiKeyAuthMiddleware"; +import { AiAgentUsageService } from "../services/aiAgentUsageService"; +import { AiAgentApiKeyService } from "../services/aiAgentApiKeyService"; +import { MultiAgentExecutionEngine } from "../services/multiAgentExecutionEngine"; +import { LlmClient } from "../services/llmClient"; +import { logger } from "../utils/logger"; +import { query } from "../database/db"; + +const router = Router(); + +// 모든 라우트에 API 키 인증 적용 +router.use(aiApiKeyAuth); + +/** + * POST /api/ai/v1/chat/completions + * OpenAI 호환 채팅 엔드포인트 → DB 프로바이더로 직접 호출 + */ +router.post("/chat/completions", async (req: AiApiKeyRequest, res: Response) => { + const startTime = Date.now(); + + try { + // 스트리밍 요청 + if (req.body.stream) { + const stream = await LlmClient.chatCompletionStream({ + model: req.body.model, + messages: req.body.messages, + max_tokens: req.body.max_tokens, + temperature: req.body.temperature, + }); + + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + stream.pipe(res); + return; + } + + // 비스트리밍 요청 + const result = await LlmClient.chatCompletion({ + model: req.body.model, + messages: req.body.messages, + max_tokens: req.body.max_tokens, + temperature: req.body.temperature, + }); + + // 사용량 추적 + const usage = result.usage; + if (usage) { + const elapsed = Date.now() - startTime; + + await AiAgentUsageService.log({ + user_id: req.apiKeyUserId || (req as any).user?.userId, + api_key_id: req.apiKey?.id, + provider_name: result.model?.split("-")[0] || "unknown", + model_name: result.model || req.body.model, + prompt_tokens: usage.prompt_tokens || 0, + completion_tokens: usage.completion_tokens || 0, + total_tokens: usage.total_tokens || 0, + response_time_ms: elapsed, + success: true, + request_path: "/v1/chat/completions", + ip_address: req.ip, + }); + + if (req.apiKey) { + await AiAgentApiKeyService.addTokenUsage(req.apiKey.id, usage.total_tokens || 0); + } + } + + res.json(result); + } catch (error: any) { + const elapsed = Date.now() - startTime; + + await AiAgentUsageService.log({ + user_id: req.apiKeyUserId || (req as any).user?.userId, + api_key_id: req.apiKey?.id, + model_name: req.body.model, + response_time_ms: elapsed, + success: false, + error_message: error.message, + request_path: "/v1/chat/completions", + ip_address: req.ip, + }); + + // LLM 프로바이더 에러 응답 전달 + const status = error.response?.status || 500; + res.status(status).json( + error.response?.data || { error: { message: error.message, type: "server_error" } } + ); + } +}); + +/** + * POST /api/ai/v1/groups/:groupId + * 멀티 에이전트 그룹 실행 + */ +router.post("/groups/:groupId", async (req: AiApiKeyRequest, res: Response) => { + const groupId = parseInt(req.params.groupId); + const { message } = req.body; + + if (!message) { + return res.status(400).json({ error: { message: "message is required", type: "invalid_request" } }); + } + + // group_id(문자열)로도 조회 가능 + let actualGroupId = groupId; + if (isNaN(groupId)) { + const group = await query( + "SELECT id FROM ai_agent_groups WHERE group_id = $1 AND status = 'active'", + [req.params.groupId] + ).catch(() => []); + if (group.length === 0) { + return res.status(404).json({ error: { message: "Group not found", type: "not_found" } }); + } + actualGroupId = group[0].id; + } + + try { + const result = await MultiAgentExecutionEngine.execute(actualGroupId, message, { + userId: req.apiKeyUserId || (req as any).user?.userId, + apiKeyId: req.apiKey?.id, + }); + + res.json({ + success: true, + data: { + group: result.groupName, + execution_mode: result.executionMode, + total_tokens: result.totalTokens, + duration_ms: result.totalDurationMs, + steps: result.steps.map((s) => ({ + order: s.executionOrder, + role: s.roleName, + agent: s.agentName, + model: s.modelName, + response: s.response, + tokens: s.tokensUsed, + duration_ms: s.durationMs, + })), + summary: result.finalSummary, + }, + }); + } catch (e: any) { + res.status(500).json({ + error: { message: e.message, type: "execution_error" }, + }); + } +}); + +/** + * GET /api/ai/v1/groups + * 사용 가능한 멀티 에이전트 그룹 목록 + */ +router.get("/groups", async (req: AiApiKeyRequest, res: Response) => { + const groups = await query( + `SELECT g.id, g.group_id, g.name, g.description, g.execution_mode, + (SELECT COUNT(*) FROM ai_agent_group_members WHERE group_id = g.id) as member_count + FROM ai_agent_groups g WHERE g.status = 'active' ORDER BY g.name` + ).catch(() => []); + res.json({ success: true, data: groups }); +}); + +/** + * GET /api/ai/v1/models + * 사용 가능한 모델 목록 (DB 프로바이더 기반) + */ +router.get("/models", async (req: AiApiKeyRequest, res: Response) => { + try { + const models = await LlmClient.listModels(); + res.json(models); + } catch { + res.json({ + object: "list", + data: [ + { id: "claude-sonnet-4-20250514", object: "model", owned_by: "anthropic" }, + { id: "gpt-4o", object: "model", owned_by: "openai" }, + { id: "gpt-4o-mini", object: "model", owned_by: "openai" }, + ], + }); + } +}); + +/** + * GET /api/ai/v1/health + * AI 엔진 상태 확인 + */ +router.get("/health", async (req: Request, res: Response) => { + try { + const models = await LlmClient.listModels(); + res.json({ + status: "running", + engine: "pipeline-native", + providers: models.data?.length || 0, + }); + } catch { + res.json({ + status: "no_providers", + engine: "pipeline-native", + providers: 0, + }); + } +}); + +export default router; diff --git a/backend-node/src/routes/pipelineDeviceConnectionRoutes.ts b/backend-node/src/routes/pipelineDeviceConnectionRoutes.ts new file mode 100644 index 00000000..b32a2995 --- /dev/null +++ b/backend-node/src/routes/pipelineDeviceConnectionRoutes.ts @@ -0,0 +1,147 @@ +import { Router, Response } from "express"; +import { PipelineDeviceConnectionService } from "../services/pipelineDeviceConnectionService"; +import { PROTOCOL_OPTIONS, PROTOCOL_DEFAULTS, TAG_DATA_TYPE_OPTIONS, ADDRESS_TYPE_OPTIONS } from "../types/pipelineDeviceTypes"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { AuthenticatedRequest } from "../types/auth"; + +const router = Router(); + +// 모든 라우트 인증 필요 +router.use(authenticateToken); + +// ===== 프로토콜 목록 (정적 경로 우선) ===== +router.get("/protocols", async (req: AuthenticatedRequest, res: Response) => { + res.json({ + success: true, + data: { protocols: PROTOCOL_OPTIONS, defaults: PROTOCOL_DEFAULTS, tagDataTypes: TAG_DATA_TYPE_OPTIONS, addressTypes: ADDRESS_TYPE_OPTIONS }, + }); +}); + +// ===== 태그 수정/삭제 (정적 경로 우선) ===== +router.put("/tags/:tagId", async (req: AuthenticatedRequest, res: Response) => { + try { + const result = await PipelineDeviceConnectionService.updateTagMapping(parseInt(req.params.tagId), req.body); + res.status(result.success ? 200 : 400).json(result); + } catch (e: any) { + res.status(500).json({ success: false, message: e.message }); + } +}); + +router.delete("/tags/:tagId", async (req: AuthenticatedRequest, res: Response) => { + try { + const result = await PipelineDeviceConnectionService.deleteTagMapping(parseInt(req.params.tagId)); + res.json(result); + } catch (e: any) { + res.status(500).json({ success: false, message: e.message }); + } +}); + +// ===== 연결 CRUD ===== +router.get("/", async (req: AuthenticatedRequest, res: Response) => { + try { + const userCompanyCode = req.user?.companyCode; + let companyCodeFilter: string | undefined; + if (userCompanyCode === "*") { + companyCodeFilter = req.query.company_code as string; + } else { + companyCodeFilter = userCompanyCode; + } + + const filter = { + protocol: req.query.protocol as string, + is_active: req.query.is_active as string, + company_code: companyCodeFilter, + search: req.query.search as string, + status: req.query.status as string, + }; + + Object.keys(filter).forEach((key) => { + if (!filter[key as keyof typeof filter]) delete filter[key as keyof typeof filter]; + }); + + const result = await PipelineDeviceConnectionService.getConnections(filter, userCompanyCode); + res.json(result); + } catch (e: any) { + res.status(500).json({ success: false, message: e.message }); + } +}); + +router.get("/:id", async (req: AuthenticatedRequest, res: Response) => { + try { + const result = await PipelineDeviceConnectionService.getConnectionById(parseInt(req.params.id)); + res.status(result.success ? 200 : 404).json(result); + } catch (e: any) { + res.status(500).json({ success: false, message: e.message }); + } +}); + +router.post("/", async (req: AuthenticatedRequest, res: Response) => { + try { + const data = { + ...req.body, + company_code: req.body.company_code || req.user?.companyCode, + created_by: req.user?.userId, + }; + const result = await PipelineDeviceConnectionService.createConnection(data); + res.status(result.success ? 201 : 400).json(result); + } catch (e: any) { + if (e.message?.includes("duplicate") || e.code === "23505") { + res.status(409).json({ success: false, message: "동일한 연결명이 이미 존재합니다." }); + } else { + res.status(500).json({ success: false, message: e.message }); + } + } +}); + +router.put("/:id", async (req: AuthenticatedRequest, res: Response) => { + try { + const result = await PipelineDeviceConnectionService.updateConnection(parseInt(req.params.id), req.body); + res.status(result.success ? 200 : 404).json(result); + } catch (e: any) { + res.status(500).json({ success: false, message: e.message }); + } +}); + +router.delete("/:id", async (req: AuthenticatedRequest, res: Response) => { + try { + const result = await PipelineDeviceConnectionService.deleteConnection(parseInt(req.params.id)); + res.json(result); + } catch (e: any) { + res.status(500).json({ success: false, message: e.message }); + } +}); + +// ===== 연결 테스트 ===== +router.post("/:id/test", async (req: AuthenticatedRequest, res: Response) => { + try { + const result = await PipelineDeviceConnectionService.testConnection(parseInt(req.params.id)); + res.json({ success: result.success, data: result }); + } catch (e: any) { + res.status(500).json({ success: false, message: e.message }); + } +}); + +// ===== 태그 매핑 ===== +router.get("/:id/tags", async (req: AuthenticatedRequest, res: Response) => { + try { + const result = await PipelineDeviceConnectionService.getTagMappings(parseInt(req.params.id)); + res.json(result); + } catch (e: any) { + res.status(500).json({ success: false, message: e.message }); + } +}); + +router.post("/:id/tags", async (req: AuthenticatedRequest, res: Response) => { + try { + const result = await PipelineDeviceConnectionService.createTagMapping(parseInt(req.params.id), req.body); + res.status(result.success ? 201 : 400).json(result); + } catch (e: any) { + if (e.message?.includes("duplicate") || e.code === "23505") { + res.status(409).json({ success: false, message: "동일한 태그명이 이미 존재합니다." }); + } else { + res.status(500).json({ success: false, message: e.message }); + } + } +}); + +export default router; diff --git a/backend-node/src/services/aiAgentApiKeyService.ts b/backend-node/src/services/aiAgentApiKeyService.ts new file mode 100644 index 00000000..fbf0be4c --- /dev/null +++ b/backend-node/src/services/aiAgentApiKeyService.ts @@ -0,0 +1,89 @@ +import crypto from "crypto"; +import { query, queryOne } from "../database/db"; +import { AiAgentApiKey, CreateApiKeyRequest } from "../types/aiAgent"; +import { logger } from "../utils/logger"; + +export class AiAgentApiKeyService { + private static generateKey(): { plainKey: string; hash: string; prefix: string } { + const randomBytes = crypto.randomBytes(32).toString("hex"); + const plainKey = `sk-pipe-${randomBytes}`; + const hash = crypto.createHash("sha256").update(plainKey).digest("hex"); + const prefix = plainKey.substring(0, 16); + return { plainKey, hash, prefix }; + } + + static async list(userId: string, isAdmin: boolean): Promise { + if (isAdmin) { + return query( + "SELECT * FROM ai_agent_api_keys ORDER BY created_at DESC" + ); + } + return query( + "SELECT * FROM ai_agent_api_keys WHERE user_id = $1 ORDER BY created_at DESC", + [userId] + ); + } + + static async create(data: CreateApiKeyRequest, userId: string, companyCode?: string): Promise<{ key: AiAgentApiKey; plainKey: string }> { + const { plainKey, hash, prefix } = this.generateKey(); + + const result = await query( + `INSERT INTO ai_agent_api_keys (name, key_hash, key_prefix, user_id, company_code, agent_id, permissions, rate_limit, monthly_token_limit, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10) + RETURNING *`, + [ + data.name, + hash, + prefix, + userId, + companyCode || null, + data.agent_id || null, + JSON.stringify(data.permissions || ["chat"]), + data.rate_limit || 60, + data.monthly_token_limit || 1000000, + data.expires_at || null, + ] + ); + + logger.info(`API 키 생성: ${prefix}... (by ${userId})`); + return { key: result[0], plainKey }; + } + + static async revoke(id: number, userId: string): Promise { + await query( + "DELETE FROM ai_agent_api_keys WHERE id = $1 AND (user_id = $2 OR $2 = 'wace')", + [id, userId] + ); + logger.info(`API 키 삭제: id=${id} (by ${userId})`); + return true; + } + + static async validateKey(plainKey: string): Promise { + const hash = crypto.createHash("sha256").update(plainKey).digest("hex"); + const key = await queryOne( + "SELECT * FROM ai_agent_api_keys WHERE key_hash = $1 AND status = 'active'", + [hash] + ); + + if (!key) return null; + + if (key.expires_at && new Date(key.expires_at) < new Date()) { + return null; + } + + // 마지막 사용 시간 업데이트 + await query( + "UPDATE ai_agent_api_keys SET last_used_at = NOW(), usage_count = usage_count + 1 WHERE id = $1", + [key.id] + ); + + return key; + } + + static async addTokenUsage(keyId: number, tokens: number): Promise { + await query( + "UPDATE ai_agent_api_keys SET total_tokens = total_tokens + $1 WHERE id = $2", + [tokens, keyId] + ); + } +} diff --git a/backend-node/src/services/aiAgentConversationService.ts b/backend-node/src/services/aiAgentConversationService.ts new file mode 100644 index 00000000..4df1573d --- /dev/null +++ b/backend-node/src/services/aiAgentConversationService.ts @@ -0,0 +1,87 @@ +import crypto from "crypto"; +import { query, queryOne } from "../database/db"; +import { AiConversation, AiMessage } from "../types/aiAgent"; + +export class AiAgentConversationService { + static async list(page: number = 1, limit: number = 20, agentId?: number): Promise<{ conversations: AiConversation[]; total: number }> { + const offset = (page - 1) * limit; + let where = "1=1"; + const params: any[] = [limit, offset]; + + if (agentId) { + where += " AND agent_id = $3"; + params.push(agentId); + } + + const totalResult = await queryOne<{ cnt: string }>( + `SELECT COUNT(*) as cnt FROM ai_agent_conversations WHERE ${where}`, + agentId ? [agentId] : [] + ); + + const conversations = await query( + `SELECT c.*, a.name as agent_name, + COALESCE(a.name, c.metadata->>'group_name') as display_name + FROM ai_agent_conversations c + LEFT JOIN ai_agents a ON c.agent_id = a.id + WHERE ${where} + ORDER BY c.updated_at DESC LIMIT $1 OFFSET $2`, + params + ); + + return { conversations, total: parseInt(totalResult?.cnt || "0") }; + } + + static async getById(id: number): Promise<{ conversation: AiConversation | null; messages: AiMessage[] }> { + const conversation = await queryOne( + `SELECT c.*, a.name as agent_name + FROM ai_agent_conversations c + LEFT JOIN ai_agents a ON c.agent_id = a.id + WHERE c.id = $1`, + [id] + ); + + const messages = conversation + ? await query( + "SELECT * FROM ai_agent_messages WHERE conversation_id = $1 ORDER BY created_at", + [id] + ) + : []; + + return { conversation, messages }; + } + + static async createConversation(agentId?: number, userId?: string, apiKeyId?: number): Promise { + const conversationId = `conv-${crypto.randomUUID()}`; + + const result = await query( + `INSERT INTO ai_agent_conversations (conversation_id, agent_id, user_id, api_key_id) + VALUES ($1, $2, $3, $4) RETURNING *`, + [conversationId, agentId || null, userId || null, apiKeyId || null] + ); + + return result[0]; + } + + static async addMessage(conversationId: number, role: string, content: string, tokenCount: number = 0, toolCalls?: any): Promise { + const result = await query( + `INSERT INTO ai_agent_messages (conversation_id, role, content, token_count, tool_calls) + VALUES ($1, $2, $3, $4, $5::jsonb) RETURNING *`, + [conversationId, role, content, tokenCount, toolCalls ? JSON.stringify(toolCalls) : null] + ); + + // 대화 통계 업데이트 + await query( + `UPDATE ai_agent_conversations + SET message_count = message_count + 1, total_tokens = total_tokens + $1, updated_at = NOW() + WHERE id = $2`, + [tokenCount, conversationId] + ); + + return result[0]; + } + + static async delete(id: number): Promise { + await query("DELETE FROM ai_agent_conversations WHERE id = $1", [id]); + return true; + } +} diff --git a/backend-node/src/services/aiAgentGroupService.ts b/backend-node/src/services/aiAgentGroupService.ts new file mode 100644 index 00000000..81868540 --- /dev/null +++ b/backend-node/src/services/aiAgentGroupService.ts @@ -0,0 +1,185 @@ +import { query, queryOne, transaction } from "../database/db"; +import { logger } from "../utils/logger"; + +export interface AgentGroup { + id: number; + group_id: string; + name: string; + description?: string; + status: string; + company_code?: string; + created_by: string; + created_at: string; + updated_at: string; + members?: GroupMember[]; +} + +export interface GroupMember { + id: number; + group_id: number; + agent_id: number; + role_name: string; + connectors: ConnectorRef[]; + execution_order: number; + config: Record; + // JOIN 필드 + agent_name?: string; + agent_model?: string; +} + +export interface ConnectorRef { + type: "database" | "rest_api" | "crawler" | "file" | "plc"; + connection_id?: number; + config_id?: number; + path?: string; + name: string; +} + +export class AiAgentGroupService { + static async list(companyCode?: string): Promise { + const groups = await query( + `SELECT g.*, + (SELECT COUNT(*) FROM ai_agent_group_members WHERE group_id = g.id) as member_count + FROM ai_agent_groups g + WHERE g.status != 'archived' + ${companyCode ? "AND (g.company_code = $1 OR g.company_code IS NULL)" : ""} + ORDER BY g.created_at DESC`, + companyCode ? [companyCode] : [] + ); + return groups; + } + + static async getById(id: number): Promise { + const group = await queryOne( + "SELECT * FROM ai_agent_groups WHERE id = $1", + [id] + ); + if (!group) return null; + + const members = await query( + `SELECT m.*, a.name as agent_name, a.model as agent_model + FROM ai_agent_group_members m + LEFT JOIN ai_agents a ON m.agent_id = a.id + WHERE m.group_id = $1 + ORDER BY m.execution_order`, + [id] + ); + group.members = members; + return group; + } + + static async create(data: { name: string; description?: string; company_code?: string; execution_mode?: string }, userId: string): Promise { + const groupId = `group-${Date.now().toString(36)}`; + const result = await query( + `INSERT INTO ai_agent_groups (group_id, name, description, company_code, created_by, execution_mode) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [groupId, data.name, data.description || null, data.company_code || null, userId, data.execution_mode || "mixed"] + ); + logger.info(`멀티 에이전트 그룹 생성: ${data.name} (by ${userId})`); + return result[0]; + } + + static async update(id: number, data: { name?: string; description?: string; status?: string; execution_mode?: string }): Promise { + const sets: string[] = []; + const params: any[] = []; + let idx = 1; + + if (data.name !== undefined) { sets.push(`name = $${idx++}`); params.push(data.name); } + if (data.description !== undefined) { sets.push(`description = $${idx++}`); params.push(data.description); } + if (data.status !== undefined) { sets.push(`status = $${idx++}`); params.push(data.status); } + if (data.execution_mode !== undefined) { sets.push(`execution_mode = $${idx++}`); params.push(data.execution_mode); } + if (sets.length === 0) return this.getById(id); + + sets.push(`updated_at = NOW()`); + params.push(id); + + const result = await query( + `UPDATE ai_agent_groups SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`, + params + ); + return result[0] || null; + } + + static async delete(id: number): Promise { + await query("UPDATE ai_agent_groups SET status = 'archived', updated_at = NOW() WHERE id = $1", [id]); + return true; + } + + // ===== 멤버 관리 ===== + + static async addMember(groupId: number, data: { + agent_id: number; + role_name: string; + connectors?: ConnectorRef[]; + execution_order?: number; + }): Promise { + const result = await query( + `INSERT INTO ai_agent_group_members (group_id, agent_id, role_name, connectors, execution_order) + VALUES ($1, $2, $3, $4::jsonb, $5) RETURNING *`, + [groupId, data.agent_id, data.role_name, JSON.stringify(data.connectors || []), data.execution_order || 1] + ); + await query("UPDATE ai_agent_groups SET updated_at = NOW() WHERE id = $1", [groupId]); + return result[0]; + } + + static async updateMember(memberId: number, data: { + role_name?: string; + connectors?: ConnectorRef[]; + execution_order?: number; + }): Promise { + const sets: string[] = []; + const params: any[] = []; + let idx = 1; + + if (data.role_name !== undefined) { sets.push(`role_name = $${idx++}`); params.push(data.role_name); } + if (data.connectors !== undefined) { sets.push(`connectors = $${idx++}::jsonb`); params.push(JSON.stringify(data.connectors)); } + if (data.execution_order !== undefined) { sets.push(`execution_order = $${idx++}`); params.push(data.execution_order); } + if (sets.length === 0) return null; + + params.push(memberId); + const result = await query( + `UPDATE ai_agent_group_members SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`, + params + ); + return result[0] || null; + } + + static async removeMember(memberId: number): Promise { + await query("DELETE FROM ai_agent_group_members WHERE id = $1", [memberId]); + return true; + } + + // ===== 사용 가능한 커넥터 목록 ===== + + static async getAvailableConnectors(): Promise { + // DB 연결 + const dbConnections = await query( + "SELECT id as connection_id, connection_name as name, db_type, host, port, database_name, 'database' as type FROM external_db_connections WHERE is_active = 'Y' ORDER BY connection_name" + ).catch(() => []); + + // REST API 연결 + const restConnections = await query( + "SELECT id as connection_id, connection_name as name, base_url, auth_type, 'rest_api' as type FROM external_rest_api_connections WHERE is_active = 'Y' ORDER BY connection_name" + ).catch(() => []); + + // 크롤링 설정 + const crawlConfigs = await query( + "SELECT id as connection_id, name, url, 'crawler' as type FROM crawl_configs WHERE is_active = true ORDER BY name" + ).catch(() => []); + + // PLC / 장비 연결 + const plcConnections = await query( + `SELECT id as connection_id, connection_name as name, host, port, protocol, status, + (SELECT COUNT(*) FROM pipeline_tag_mappings WHERE connection_id = pipeline_device_connections.id AND is_active = 'Y') as tag_count, + 'plc' as type + FROM pipeline_device_connections WHERE is_active = 'Y' ORDER BY connection_name` + ).catch(() => []); + + return [ + ...dbConnections.map((c: any) => ({ ...c, type: "database" })), + ...restConnections.map((c: any) => ({ ...c, type: "rest_api" })), + ...crawlConfigs.map((c: any) => ({ ...c, type: "crawler" })), + ...plcConnections.map((c: any) => ({ ...c, type: "plc" })), + ]; + } +} diff --git a/backend-node/src/services/aiAgentProviderService.ts b/backend-node/src/services/aiAgentProviderService.ts new file mode 100644 index 00000000..4023ccb5 --- /dev/null +++ b/backend-node/src/services/aiAgentProviderService.ts @@ -0,0 +1,95 @@ +import { query, queryOne } from "../database/db"; +import { AiLlmProvider, CreateProviderRequest } from "../types/aiAgent"; +import { EncryptUtil } from "../utils/encryptUtil"; +import { logger } from "../utils/logger"; + +export class AiAgentProviderService { + static async list(): Promise[]> { + const providers = await query( + "SELECT * FROM ai_llm_providers ORDER BY priority, name" + ); + // API 키는 마스킹해서 반환 + return providers.map(({ api_key_encrypted, ...rest }) => ({ + ...rest, + api_key_masked: api_key_encrypted ? "****" + api_key_encrypted.slice(-4) : "", + })) as any; + } + + static async getById(id: number): Promise { + return queryOne( + "SELECT * FROM ai_llm_providers WHERE id = $1", + [id] + ); + } + + static async create(data: CreateProviderRequest): Promise { + const encrypted = EncryptUtil.encrypt(data.api_key); + + const result = await query( + `INSERT INTO ai_llm_providers (name, display_name, api_key_encrypted, model_name, endpoint, priority, max_tokens, temperature, cost_per_1k_input, cost_per_1k_output) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + data.name, + data.display_name, + encrypted, + data.model_name, + data.endpoint || null, + data.priority || 1, + data.max_tokens || 4096, + data.temperature || 0.7, + data.cost_per_1k_input || 0, + data.cost_per_1k_output || 0, + ] + ); + + logger.info(`LLM 프로바이더 추가: ${data.name} (${data.model_name})`); + return result[0]; + } + + static async update(id: number, data: Partial & { is_active?: boolean }): Promise { + const sets: string[] = []; + const params: any[] = []; + let idx = 1; + + if (data.display_name !== undefined) { sets.push(`display_name = $${idx++}`); params.push(data.display_name); } + if (data.api_key !== undefined) { sets.push(`api_key_encrypted = $${idx++}`); params.push(EncryptUtil.encrypt(data.api_key)); } + if (data.model_name !== undefined) { sets.push(`model_name = $${idx++}`); params.push(data.model_name); } + if (data.endpoint !== undefined) { sets.push(`endpoint = $${idx++}`); params.push(data.endpoint); } + if (data.priority !== undefined) { sets.push(`priority = $${idx++}`); params.push(data.priority); } + if (data.max_tokens !== undefined) { sets.push(`max_tokens = $${idx++}`); params.push(data.max_tokens); } + if (data.temperature !== undefined) { sets.push(`temperature = $${idx++}`); params.push(data.temperature); } + if (data.cost_per_1k_input !== undefined) { sets.push(`cost_per_1k_input = $${idx++}`); params.push(data.cost_per_1k_input); } + if (data.cost_per_1k_output !== undefined) { sets.push(`cost_per_1k_output = $${idx++}`); params.push(data.cost_per_1k_output); } + if (data.is_active !== undefined) { sets.push(`is_active = $${idx++}`); params.push(data.is_active); } + + if (sets.length === 0) return this.getById(id); + + sets.push(`updated_at = NOW()`); + params.push(id); + + const result = await query( + `UPDATE ai_llm_providers SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`, + params + ); + + return result[0] || null; + } + + static async delete(id: number): Promise { + await query("DELETE FROM ai_llm_providers WHERE id = $1", [id]); + return true; + } + + static async getActiveProviders(): Promise { + return query( + "SELECT * FROM ai_llm_providers WHERE is_active = true ORDER BY priority" + ); + } + + static async getDecryptedKey(id: number): Promise { + const provider = await this.getById(id); + if (!provider) return null; + return EncryptUtil.decrypt(provider.api_key_encrypted); + } +} diff --git a/backend-node/src/services/aiAgentService.ts b/backend-node/src/services/aiAgentService.ts new file mode 100644 index 00000000..3099a188 --- /dev/null +++ b/backend-node/src/services/aiAgentService.ts @@ -0,0 +1,101 @@ +import { query, queryOne, transaction } from "../database/db"; +import { AiAgent, CreateAgentRequest, UpdateAgentRequest } from "../types/aiAgent"; +import { logger } from "../utils/logger"; + +export class AiAgentService { + static async list(filters?: { status?: string; company_code?: string; search?: string }): Promise { + let sql = "SELECT * FROM ai_agents WHERE 1=1"; + const params: any[] = []; + let idx = 1; + + if (filters?.status) { + sql += ` AND status = $${idx++}`; + params.push(filters.status); + } + if (filters?.company_code) { + sql += ` AND (company_code = $${idx++} OR company_code IS NULL)`; + params.push(filters.company_code); + } + if (filters?.search) { + sql += ` AND (name ILIKE $${idx} OR agent_id ILIKE $${idx} OR description ILIKE $${idx})`; + params.push(`%${filters.search}%`); + idx++; + } + + sql += " ORDER BY created_at DESC"; + return query(sql, params); + } + + static async getById(id: number): Promise { + return queryOne("SELECT * FROM ai_agents WHERE id = $1", [id]); + } + + static async getByAgentId(agentId: string): Promise { + return queryOne("SELECT * FROM ai_agents WHERE agent_id = $1", [agentId]); + } + + static async create(data: CreateAgentRequest, userId: string): Promise { + const existing = await this.getByAgentId(data.agent_id); + if (existing) { + throw new Error("이미 존재하는 에이전트 ID입니다."); + } + + const result = await query( + `INSERT INTO ai_agents (agent_id, name, description, model, system_prompt, tools, config, company_code, created_by) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9) + RETURNING *`, + [ + data.agent_id, + data.name, + data.description || null, + data.model || "claude-sonnet-4-20250514", + data.system_prompt || null, + JSON.stringify(data.tools || []), + JSON.stringify(data.config || {}), + data.company_code || null, + userId, + ] + ); + + logger.info(`에이전트 생성: ${data.agent_id} (by ${userId})`); + return result[0]; + } + + static async update(id: number, data: UpdateAgentRequest): Promise { + const sets: string[] = []; + const params: any[] = []; + let idx = 1; + + if (data.name !== undefined) { sets.push(`name = $${idx++}`); params.push(data.name); } + if (data.description !== undefined) { sets.push(`description = $${idx++}`); params.push(data.description); } + if (data.model !== undefined) { sets.push(`model = $${idx++}`); params.push(data.model); } + if (data.system_prompt !== undefined) { sets.push(`system_prompt = $${idx++}`); params.push(data.system_prompt); } + if (data.tools !== undefined) { sets.push(`tools = $${idx++}::jsonb`); params.push(JSON.stringify(data.tools)); } + if (data.config !== undefined) { sets.push(`config = $${idx++}::jsonb`); params.push(JSON.stringify(data.config)); } + if (data.status !== undefined) { sets.push(`status = $${idx++}`); params.push(data.status); } + + if (sets.length === 0) return this.getById(id); + + sets.push(`updated_at = NOW()`); + params.push(id); + + const result = await query( + `UPDATE ai_agents SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`, + params + ); + + return result[0] || null; + } + + static async delete(id: number): Promise { + const result = await query( + "UPDATE ai_agents SET status = 'archived', updated_at = NOW() WHERE id = $1", + [id] + ); + return true; + } + + static async getActiveAgents(): Promise { + return query("SELECT * FROM ai_agents WHERE status = 'active' ORDER BY name"); + } +} diff --git a/backend-node/src/services/aiAgentUsageService.ts b/backend-node/src/services/aiAgentUsageService.ts new file mode 100644 index 00000000..84fc3ac5 --- /dev/null +++ b/backend-node/src/services/aiAgentUsageService.ts @@ -0,0 +1,88 @@ +import { query, queryOne } from "../database/db"; +import { AiUsageLog, UsageSummary } from "../types/aiAgent"; +import { logger } from "../utils/logger"; + +export class AiAgentUsageService { + static async log(data: Partial): Promise { + await query( + `INSERT INTO ai_agent_usage_logs (user_id, api_key_id, agent_id, conversation_id, provider_name, model_name, prompt_tokens, completion_tokens, total_tokens, cost_usd, response_time_ms, success, error_message, request_path, ip_address) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`, + [ + data.user_id || null, + data.api_key_id || null, + data.agent_id || null, + data.conversation_id || null, + data.provider_name || null, + data.model_name || null, + data.prompt_tokens || 0, + data.completion_tokens || 0, + data.total_tokens || 0, + data.cost_usd || 0, + data.response_time_ms || null, + data.success !== false, + data.error_message || null, + data.request_path || null, + data.ip_address || null, + ] + ); + } + + static async getSummary(): Promise { + const todayResult = await queryOne<{ tokens: string; requests: string; cost: string }>( + `SELECT COALESCE(SUM(total_tokens), 0) as tokens, COUNT(*) as requests, COALESCE(SUM(cost_usd), 0) as cost + FROM ai_agent_usage_logs WHERE created_at >= CURRENT_DATE` + ); + + const monthResult = await queryOne<{ tokens: string; requests: string; cost: string }>( + `SELECT COALESCE(SUM(total_tokens), 0) as tokens, COUNT(*) as requests, COALESCE(SUM(cost_usd), 0) as cost + FROM ai_agent_usage_logs WHERE created_at >= DATE_TRUNC('month', CURRENT_DATE)` + ); + + const agentCount = await queryOne<{ cnt: string }>( + "SELECT COUNT(*) as cnt FROM ai_agents WHERE status = 'active'" + ); + + const keyCount = await queryOne<{ cnt: string }>( + "SELECT COUNT(*) as cnt FROM ai_agent_api_keys WHERE status = 'active'" + ); + + return { + today_tokens: parseInt(todayResult?.tokens || "0"), + today_requests: parseInt(todayResult?.requests || "0"), + today_cost: parseFloat(todayResult?.cost || "0"), + month_tokens: parseInt(monthResult?.tokens || "0"), + month_requests: parseInt(monthResult?.requests || "0"), + month_cost: parseFloat(monthResult?.cost || "0"), + active_agents: parseInt(agentCount?.cnt || "0"), + active_keys: parseInt(keyCount?.cnt || "0"), + }; + } + + static async getLogs(page: number = 1, limit: number = 20): Promise<{ logs: AiUsageLog[]; total: number }> { + const offset = (page - 1) * limit; + + const totalResult = await queryOne<{ cnt: string }>( + "SELECT COUNT(*) as cnt FROM ai_agent_usage_logs" + ); + + const logs = await query( + "SELECT * FROM ai_agent_usage_logs ORDER BY created_at DESC LIMIT $1 OFFSET $2", + [limit, offset] + ); + + return { logs, total: parseInt(totalResult?.cnt || "0") }; + } + + static async getDailyUsage(days: number = 30): Promise { + return query( + `SELECT DATE(created_at) as date, + SUM(total_tokens) as tokens, + COUNT(*) as requests, + SUM(cost_usd) as cost + FROM ai_agent_usage_logs + WHERE created_at >= CURRENT_DATE - INTERVAL '${days} days' + GROUP BY DATE(created_at) + ORDER BY date` + ); + } +} diff --git a/backend-node/src/services/aiAnalysisLogService.ts b/backend-node/src/services/aiAnalysisLogService.ts new file mode 100644 index 00000000..accfac21 --- /dev/null +++ b/backend-node/src/services/aiAnalysisLogService.ts @@ -0,0 +1,72 @@ +import { query, queryOne } from "../database/db"; +import { logger } from "../utils/logger"; + +export class AiAnalysisLogService { + /** + * 분석 결과 저장 + */ + static async save(data: { + group_id?: number; + agent_id?: number; + schedule_id?: number; + execution_type?: string; + input_message: string; + analysis_result: string; + prediction?: any; + tokens_used?: number; + duration_ms?: number; + metadata?: any; + }): Promise { + const result = await query( + `INSERT INTO ai_analysis_logs (group_id, agent_id, schedule_id, execution_type, input_message, analysis_result, prediction, tokens_used, duration_ms, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10::jsonb) RETURNING *`, + [ + data.group_id || null, + data.agent_id || null, + data.schedule_id || null, + data.execution_type || "manual", + data.input_message, + data.analysis_result, + data.prediction ? JSON.stringify(data.prediction) : null, + data.tokens_used || 0, + data.duration_ms || 0, + JSON.stringify(data.metadata || {}), + ] + ); + return result[0]; + } + + /** + * 최근 이력 조회 (에이전트가 참고할 수 있도록) + */ + static async getRecentLogs(groupId: number, days: number = 30, limit: number = 20): Promise { + return query( + `SELECT id, execution_type, input_message, analysis_result, prediction, actual_result, accuracy_score, created_at + FROM ai_analysis_logs + WHERE group_id = $1 AND created_at >= NOW() - INTERVAL '${days} days' + ORDER BY created_at DESC LIMIT $2`, + [groupId, limit] + ); + } + + /** + * 예측 정확도 업데이트 (실제 결과 입력 시) + */ + static async updateActualResult(logId: number, actualResult: any, accuracyScore: number): Promise { + await query( + "UPDATE ai_analysis_logs SET actual_result = $1::jsonb, accuracy_score = $2 WHERE id = $3", + [JSON.stringify(actualResult), accuracyScore, logId] + ); + } + + /** + * 평균 예측 정확도 조회 + */ + static async getAverageAccuracy(groupId: number): Promise { + const result = await queryOne<{ avg: string }>( + "SELECT AVG(accuracy_score)::text as avg FROM ai_analysis_logs WHERE group_id = $1 AND accuracy_score IS NOT NULL", + [groupId] + ); + return parseFloat(result?.avg || "0"); + } +} diff --git a/backend-node/src/services/aiSchedulerService.ts b/backend-node/src/services/aiSchedulerService.ts new file mode 100644 index 00000000..ea4a1c9a --- /dev/null +++ b/backend-node/src/services/aiSchedulerService.ts @@ -0,0 +1,185 @@ +import cron from "node-cron"; +import { query, queryOne } from "../database/db"; +import { MultiAgentExecutionEngine } from "./multiAgentExecutionEngine"; +import { logger } from "../utils/logger"; +import axios from "axios"; + +interface ScheduleJob { + id: number; + cronTask: cron.ScheduledTask; +} + +const activeJobs = new Map(); + +export class AiSchedulerService { + /** + * 스케줄 CRUD + */ + static async list(): Promise { + return query( + `SELECT s.*, g.name as group_name + FROM ai_agent_schedules s + LEFT JOIN ai_agent_groups g ON s.group_id = g.id + ORDER BY s.created_at DESC` + ); + } + + static async create(data: { + name: string; + group_id: number; + cron_expression: string; + input_message: string; + notification?: any; + }, userId: string): Promise { + // cron 표현식 유효성 체크 + if (!cron.validate(data.cron_expression)) { + throw new Error("유효하지 않은 cron 표현식입니다."); + } + + const result = await query( + `INSERT INTO ai_agent_schedules (name, group_id, cron_expression, input_message, notification, created_by) + VALUES ($1, $2, $3, $4, $5::jsonb, $6) RETURNING *`, + [data.name, data.group_id, data.cron_expression, data.input_message, JSON.stringify(data.notification || {}), userId] + ); + + const schedule = result[0]; + this.registerJob(schedule); + logger.info(`AI 스케줄 생성: ${data.name} (${data.cron_expression})`); + return schedule; + } + + static async update(id: number, data: any): Promise { + const sets: string[] = []; + const params: any[] = []; + let idx = 1; + + if (data.name !== undefined) { sets.push(`name = $${idx++}`); params.push(data.name); } + if (data.cron_expression !== undefined) { + if (!cron.validate(data.cron_expression)) throw new Error("유효하지 않은 cron 표현식입니다."); + sets.push(`cron_expression = $${idx++}`); params.push(data.cron_expression); + } + if (data.input_message !== undefined) { sets.push(`input_message = $${idx++}`); params.push(data.input_message); } + if (data.notification !== undefined) { sets.push(`notification = $${idx++}::jsonb`); params.push(JSON.stringify(data.notification)); } + if (data.is_active !== undefined) { sets.push(`is_active = $${idx++}`); params.push(data.is_active); } + + if (sets.length === 0) return null; + sets.push(`updated_at = NOW()`); + params.push(id); + + const result = await query(`UPDATE ai_agent_schedules SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`, params); + const schedule = result[0]; + + // 재등록 + this.unregisterJob(id); + if (schedule?.is_active) this.registerJob(schedule); + + return schedule; + } + + static async delete(id: number): Promise { + this.unregisterJob(id); + await query("DELETE FROM ai_agent_schedules WHERE id = $1", [id]); + } + + /** + * 스케줄 작업 등록 + */ + private static registerJob(schedule: any): void { + if (!schedule.is_active) return; + + const task = cron.schedule(schedule.cron_expression, async () => { + logger.info(`AI 스케줄 실행: ${schedule.name} (ID: ${schedule.id})`); + + try { + const result = await MultiAgentExecutionEngine.execute( + schedule.group_id, + schedule.input_message, + { userId: schedule.created_by } + ); + + // 실행 기록 업데이트 + await query( + "UPDATE ai_agent_schedules SET last_run_at = NOW(), run_count = run_count + 1 WHERE id = $1", + [schedule.id] + ); + + // 알림 발송 + await this.sendNotification(schedule, result); + + logger.info(`AI 스케줄 완료: ${schedule.name} - ${result.totalTokens} tokens`); + } catch (e: any) { + logger.error(`AI 스케줄 실패: ${schedule.name} - ${e.message}`); + } + }); + + activeJobs.set(schedule.id, { id: schedule.id, cronTask: task }); + } + + private static unregisterJob(id: number): void { + const job = activeJobs.get(id); + if (job) { + job.cronTask.stop(); + activeJobs.delete(id); + } + } + + /** + * 알림 발송 + */ + private static async sendNotification(schedule: any, result: any): Promise { + const notification = schedule.notification || {}; + + // 시스템 공지 + if (notification.system_notice) { + try { + await query( + `INSERT INTO system_notice (title, content, type, is_active, created_by, created_at) + VALUES ($1, $2, 'info', true, 'AI_SCHEDULER', NOW())`, + [ + `[AI] ${schedule.name} 실행 결과`, + result.finalSummary.substring(0, 2000), + ] + ); + } catch (e) { logger.warn("시스템 공지 저장 실패:", e); } + } + + // 웹훅 (슬랙 등) + if (notification.webhook) { + try { + await axios.post(notification.webhook, { + text: `🤖 [${schedule.name}] 실행 완료\n\n${result.finalSummary.substring(0, 1000)}`, + }, { timeout: 10000 }); + } catch (e) { logger.warn("웹훅 발송 실패:", e); } + } + + // 이메일 + if (notification.email && notification.email.length > 0) { + try { + // 기존 메일 발송 서비스 활용 + const { mailSendSimpleService } = await import("./mailSendSimpleService"); + for (const to of notification.email) { + await mailSendSimpleService.sendMail({ + to, + subject: `[AI 분석] ${schedule.name} 실행 결과`, + html: `

${schedule.name} 분석 결과

${result.finalSummary.substring(0, 3000)}
`, + }).catch(() => {}); + } + } catch (e) { logger.warn("이메일 발송 실패:", e); } + } + } + + /** + * 서버 시작 시 활성 스케줄 모두 등록 + */ + static async initializeSchedules(): Promise { + try { + const schedules = await query("SELECT * FROM ai_agent_schedules WHERE is_active = true"); + for (const schedule of schedules) { + this.registerJob(schedule); + } + logger.info(`AI 스케줄러 초기화: ${schedules.length}개 활성 스케줄`); + } catch (e) { + logger.warn("AI 스케줄러 초기화 실패:", e); + } + } +} diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index 8feba9d9..b46f0a4d 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -132,7 +132,7 @@ export class BatchSchedulerService { logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id}, type: ${config.execution_type || "mapping"})`); // 상세 조회 (매핑 또는 노드플로우 정보가 없을 수 있음) - if (!config.execution_type || config.execution_type === "mapping") { + if (!config.execution_type || config.execution_type === "mapping" || config.execution_type === "rest_api_sync") { if (!config.batch_mappings || config.batch_mappings.length === 0) { const fullConfig = await BatchService.getBatchConfigById(config.id); if (fullConfig.success && fullConfig.data) { @@ -171,6 +171,15 @@ export class BatchSchedulerService { if (config.execution_type === "node_flow") { result = await this.executeNodeFlow(config); + } else if (config.execution_type === "ai_agent") { + result = await this.executeAiAgent(config); + } else if (config.execution_type === "rest_api_sync") { + // REST API 동기화 (mapping과 동일 로직이지만 타입 구분) + result = await this.executeBatchMappings(config); + } else if (config.execution_type === "device_collection") { + result = await this.executeDeviceCollection(config); + } else if (config.execution_type === "crawling") { + result = await this.executeCrawling(config); } else { result = await this.executeBatchMappings(config); } @@ -248,6 +257,137 @@ export class BatchSchedulerService { }; } + /** + * AI 멀티 에이전트 실행 - MultiAgentExecutionEngine에 위임 + */ + private static async executeAiAgent(config: any) { + const { MultiAgentExecutionEngine } = await import("./multiAgentExecutionEngine"); + const { AiAnalysisLogService } = await import("./aiAnalysisLogService"); + + const groupId = config.node_flow_context?.ai_group_id || config.ai_group_id; + const inputMessage = config.node_flow_context?.ai_input_message || config.description || "분석을 실행해주세요"; + + if (!groupId) { + throw new Error("AI 에이전트 그룹 ID가 설정되지 않았습니다."); + } + + logger.info(`AI 에이전트 실행: groupId=${groupId}, batch=${config.batch_name}`); + + const result = await MultiAgentExecutionEngine.execute(groupId, inputMessage, { + userId: config.created_by || "batch_scheduler", + }); + + // 알림 발송 (notification 설정이 있으면) + const notification = config.node_flow_context?.notification; + if (notification) { + // 시스템 공지 + if (notification.system_notice) { + try { + const { query: dbQuery } = await import("../database/db"); + await dbQuery( + `INSERT INTO system_notice (title, content, type, is_active, created_by, created_at) + VALUES ($1, $2, 'info', true, 'AI_BATCH', NOW())`, + [`[AI] ${config.batch_name} 실행 결과`, result.finalSummary.substring(0, 2000)] + ); + } catch { /* ignore */ } + } + // 웹훅 + if (notification.webhook) { + try { + const axios = (await import("axios")).default; + await axios.post(notification.webhook, { + text: `🤖 [${config.batch_name}] 실행 완료\n${result.finalSummary.substring(0, 1000)}`, + }, { timeout: 10000 }); + } catch { /* ignore */ } + } + } + + return { + totalRecords: result.steps.length, + successRecords: result.steps.filter((s) => !s.response.startsWith("[실행 실패]")).length, + failedRecords: result.steps.filter((s) => s.response.startsWith("[실행 실패]")).length, + }; + } + + /** + * 장비 데이터 수집 실행 — 실제 PLC 통신 + */ + private static async executeDeviceCollection(config: any) { + const context = config.node_flow_context || {}; + const connectionId = context.device_connection_id; + + if (!connectionId) { + throw new Error("장비 연결 ID가 설정되지 않았습니다."); + } + + // DeviceCollectorService로 실제 PLC 데이터 수집 + const { collectDevice } = await import("./collector/deviceCollectorService"); + const result = await collectDevice(connectionId); + + const tagCount = Object.keys(result.tags).length; + const successCount = Object.values(result.tags).filter(v => v !== null).length; + const failedCount = tagCount - successCount; + + logger.info( + `장비 데이터 수집 완료: ${result.connectionName} (${result.protocol}) - ` + + `${successCount}/${tagCount}개 태그 | PLC: ${result.plcState}` + ); + + // 대상 테이블에 수집 결과 저장 (설정된 경우) + const targetTable = context.target_table; + if (targetTable) { + try { + await query( + `INSERT INTO ${targetTable} (connection_id, connection_name, collected_at, plc_state, tag_values, error_message) + VALUES ($1, $2, NOW(), $3, $4::jsonb, $5)`, + [connectionId, result.connectionName, result.plcState, JSON.stringify(result.tags), result.errorMessage] + ); + } catch (err) { + logger.warn(`수집 결과 저장 실패 (${targetTable}): ${(err as Error).message}`); + } + } + + return { totalRecords: tagCount, successRecords: successCount, failedRecords: failedCount }; + } + + /** + * 크롤링 실행 + */ + private static async executeCrawling(config: any) { + const context = config.node_flow_context || {}; + const configId = context.crawl_config_id; + + if (!configId) { + throw new Error("크롤링 설정 ID가 지정되지 않았습니다."); + } + + const crawlConfig = await query("SELECT * FROM crawl_configs WHERE id = $1", [configId]); + if (!crawlConfig.length) throw new Error("크롤링 설정을 찾을 수 없습니다."); + + const cfg = crawlConfig[0]; + logger.info(`크롤링 실행: ${cfg.name} (${cfg.url})`); + + // 간단한 HTTP GET으로 데이터 수집 + try { + const axios = (await import("axios")).default; + const response = await axios.get(cfg.url, { timeout: 30000 }); + + const targetTable = context.target_table; + if (targetTable) { + // 결과를 지정된 테이블에 저장 + await query( + `INSERT INTO ${targetTable} (url, content, status_code, crawled_at) VALUES ($1, $2, $3, NOW())`, + [cfg.url, typeof response.data === "string" ? response.data : JSON.stringify(response.data), response.status] + ).catch(() => {}); + } + + return { totalRecords: 1, successRecords: 1, failedRecords: 0 }; + } catch (e: any) { + logger.warn(`크롤링 실패: ${cfg.url} - ${e.message}`); + return { totalRecords: 1, successRecords: 0, failedRecords: 1 }; + } + } + /** * 배치 매핑 실행 (수동 실행과 동일한 로직) */ diff --git a/backend-node/src/services/collector/deviceCollectorService.ts b/backend-node/src/services/collector/deviceCollectorService.ts new file mode 100644 index 00000000..044ec8b2 --- /dev/null +++ b/backend-node/src/services/collector/deviceCollectorService.ts @@ -0,0 +1,324 @@ +/** + * Device Collector Service + * - pipeline_device_connections + pipeline_tag_mappings 설정 기반 + * - 프로토콜별 PLC 읽기 (XGT, Modbus 등) + * - 읽은 데이터 → MQTT 발행 + DB 저장 + * - 오프라인 버퍼 (발행 실패 시 재시도) + * + * Python data-collector의 EdgeAgent + CollectorManager 포팅 + */ + +import { query } from "../../database/db"; +import { logger } from "../../utils/logger"; +import { XgtClient, getXgtClient, closeAllXgtConnections } from "./protocols/xgtClient"; +import { ModbusClient } from "./protocols/modbusClient"; +import type { XgtTagConfig, XgtReadResult } from "./protocols/xgtClient"; +import type { ModbusTagConfig, ModbusReadResult } from "./protocols/modbusClient"; + +// ─── 타입 ────────────────────────────────────────── + +interface DeviceConnection { + id: number; + connection_name: string; + protocol: string; + host: string; + port: number; + protocol_config: Record; + polling_interval_ms: number; + timeout_ms: number; + retry_count: number; + status: string; + company_code: string; +} + +interface TagMapping { + id: number; + connection_id: number; + tag_name: string; + tag_display_name: string | null; + tag_unit: string | null; + tag_data_type: string; + address: string; + address_type: string | null; + scale_factor: number; + offset_value: number; + min_value: number | null; + max_value: number | null; +} + +export interface CollectedData { + connectionId: number; + connectionName: string; + protocol: string; + companyCode: string; + timestamp: string; + plcState: "connected" | "disconnected" | "error"; + errorMessage: string | null; + tags: Record; +} + +// ─── 폴링 타이머 관리 ───────────────────────────── + +const pollingTimers = new Map(); +const clientCache = new Map(); + +// ─── 오프라인 버퍼 (메모리 기반, 추후 SQLite 확장 가능) ─── + +const retryQueue: CollectedData[] = []; +const MAX_RETRY_QUEUE = 10000; + +// ─── MQTT 발행 (옵션) ───────────────────────────── + +let mqttClient: { publish: (topic: string, message: string) => void } | null = null; +let mqttConfig: { brokerUrl: string; topic: string } | null = null; + +export function setMqttPublisher(config: { brokerUrl: string; topic: string }, client: { publish: (topic: string, message: string) => void }) { + mqttConfig = config; + mqttClient = client; + logger.info(`[Collector] MQTT 퍼블리셔 설정: ${config.brokerUrl} → ${config.topic}`); +} + +// ─── 태그 매핑 → 프로토콜 태그 변환 ──────────────── + +function toXgtTags(tags: TagMapping[]): XgtTagConfig[] { + return tags.map(t => ({ + tagName: t.tag_name, + address: t.address, + dataType: mapDataType(t.tag_data_type), + scaleFactor: t.scale_factor ?? 1, + offsetValue: t.offset_value ?? 0, + })); +} + +function toModbusTags(tags: TagMapping[]): ModbusTagConfig[] { + return tags.map(t => ({ + tagName: t.tag_name, + address: t.address, + dataType: t.tag_data_type as ModbusTagConfig["dataType"], + scaleFactor: t.scale_factor ?? 1, + offsetValue: t.offset_value ?? 0, + })); +} + +function mapDataType(dt: string): "BOOL" | "INT16" | "UINT16" | "INT32" | "UINT32" | "FLOAT32" { + switch (dt.toUpperCase()) { + case "BOOLEAN": return "BOOL"; + case "INT16": return "INT16"; + case "INT32": return "INT32"; + case "FLOAT32": return "FLOAT32"; + case "FLOAT64": return "FLOAT32"; // Node.js에서는 FLOAT32로 처리 + default: return "INT16"; + } +} + +// ─── 단일 디바이스 수집 실행 ────────────────────── + +export async function collectDevice(connectionId: number): Promise { + // DB에서 연결 + 태그 조회 + const connections = await query( + "SELECT * FROM pipeline_device_connections WHERE id = $1", + [connectionId] + ); + if (!connections.length) throw new Error(`연결 ID ${connectionId}를 찾을 수 없습니다.`); + const device = connections[0]; + + const tags = await query( + "SELECT * FROM pipeline_tag_mappings WHERE connection_id = $1 AND is_active = 'Y' ORDER BY tag_name", + [connectionId] + ); + if (!tags.length) throw new Error(`태그가 없습니다 (connection_id=${connectionId})`); + + const result: CollectedData = { + connectionId: device.id, + connectionName: device.connection_name, + protocol: device.protocol, + companyCode: device.company_code || "", + timestamp: new Date().toISOString(), + plcState: "disconnected", + errorMessage: null, + tags: {}, + }; + + try { + switch (device.protocol) { + case "PLC_ETHERNET": { + // LS XGT FEnet + const xgtPort = device.port || 2004; + const client = getXgtClient(device.host, xgtPort, device.timeout_ms || 3000); + if (!client.isConnected()) await client.connect(); + clientCache.set(device.id, client); + + const xgtTags = toXgtTags(tags); + const readings = await client.readTags(xgtTags); + + for (const r of readings) { + result.tags[r.tagName] = r.quality === "good" ? r.value : null; + } + result.plcState = "connected"; + break; + } + + case "MODBUS_TCP": { + const unitId = (device.protocol_config?.unit_id as number) || 1; + let client = clientCache.get(device.id) as ModbusClient; + if (!client || !client.isConnected()) { + client = new ModbusClient(device.host, device.port || 502, unitId, device.timeout_ms || 3000); + await client.connect(); + clientCache.set(device.id, client); + } + + const modbusTags = toModbusTags(tags); + const readings = await client.readTags(modbusTags); + + for (const r of readings) { + result.tags[r.tagName] = r.quality === "good" ? r.value : null; + } + result.plcState = "connected"; + break; + } + + default: + throw new Error(`지원하지 않는 프로토콜: ${device.protocol}`); + } + + // 연결 상태 업데이트 + await query( + "UPDATE pipeline_device_connections SET status = 'active', last_test_date = NOW(), last_test_result = 'success', last_test_message = $1 WHERE id = $2", + [`수집 성공: ${Object.keys(result.tags).length}개 태그`, device.id] + ).catch(() => {}); + + } catch (err) { + result.plcState = "error"; + result.errorMessage = (err as Error).message; + logger.error(`[Collector] 수집 실패 (${device.connection_name}): ${result.errorMessage}`); + + await query( + "UPDATE pipeline_device_connections SET status = 'error', last_test_date = NOW(), last_test_result = 'failure', last_test_message = $1 WHERE id = $2", + [result.errorMessage, device.id] + ).catch(() => {}); + } + + return result; +} + +// ─── 수집 결과 발행 ─────────────────────────────── + +async function publishData(data: CollectedData): Promise { + // 1. MQTT 발행 + if (mqttClient && mqttConfig) { + try { + const topic = `${mqttConfig.topic}/${data.companyCode}/${data.connectionId}`; + mqttClient.publish(topic, JSON.stringify(data)); + } catch (err) { + logger.warn(`[Collector] MQTT 발행 실패 — 재시도 큐에 추가`); + if (retryQueue.length < MAX_RETRY_QUEUE) retryQueue.push(data); + } + } + + // 2. DB 저장 (collected_data 테이블이 있으면) + try { + await query( + `INSERT INTO pipeline_collected_data (connection_id, collected_at, plc_state, tag_values, error_message) + VALUES ($1, $2, $3, $4::jsonb, $5) + ON CONFLICT DO NOTHING`, + [data.connectionId, data.timestamp, data.plcState, JSON.stringify(data.tags), data.errorMessage] + ); + } catch { + // 테이블이 없을 수 있음 — 무시 + } +} + +// ─── 폴링 시작/중지 ────────────────────────────── + +export async function startPolling(connectionId: number): Promise { + if (pollingTimers.has(connectionId)) { + logger.warn(`[Collector] 이미 폴링 중: connection_id=${connectionId}`); + return; + } + + const connections = await query( + "SELECT * FROM pipeline_device_connections WHERE id = $1 AND is_active = 'Y'", + [connectionId] + ); + if (!connections.length) throw new Error(`활성 연결을 찾을 수 없습니다: ${connectionId}`); + + const device = connections[0]; + const interval = device.polling_interval_ms || 1000; + + logger.info(`[Collector] 폴링 시작: ${device.connection_name} (${device.protocol}, ${interval}ms 간격)`); + + // 즉시 한 번 실행 + const data = await collectDevice(connectionId); + await publishData(data); + + // 주기적 폴링 + const timer = setInterval(async () => { + try { + const collected = await collectDevice(connectionId); + await publishData(collected); + } catch (err) { + logger.error(`[Collector] 폴링 에러 (${device.connection_name}): ${(err as Error).message}`); + } + }, interval); + + pollingTimers.set(connectionId, timer); +} + +export function stopPolling(connectionId: number): void { + const timer = pollingTimers.get(connectionId); + if (timer) { + clearInterval(timer); + pollingTimers.delete(connectionId); + logger.info(`[Collector] 폴링 중지: connection_id=${connectionId}`); + } + + // 클라이언트 연결도 정리 + const client = clientCache.get(connectionId); + if (client) { + client.disconnect(); + clientCache.delete(connectionId); + } +} + +export function stopAllPolling(): void { + for (const [id] of pollingTimers) { + stopPolling(id); + } + closeAllXgtConnections(); + logger.info("[Collector] 모든 폴링 중지"); +} + +// ─── 활성 연결 전체 폴링 시작 ──────────────────── + +export async function startAllActivePolling(): Promise { + const connections = await query( + "SELECT * FROM pipeline_device_connections WHERE is_active = 'Y' AND status != 'error' ORDER BY id" + ); + + let started = 0; + for (const conn of connections) { + try { + await startPolling(conn.id); + started++; + } catch (err) { + logger.error(`[Collector] 폴링 시작 실패 (${conn.connection_name}): ${(err as Error).message}`); + } + } + + logger.info(`[Collector] 전체 폴링 시작: ${started}/${connections.length}개 연결`); + return started; +} + +// ─── 상태 조회 ──────────────────────────────────── + +export function getPollingStatus(): { connectionId: number; active: boolean }[] { + const result: { connectionId: number; active: boolean }[] = []; + for (const [id] of pollingTimers) { + result.push({ connectionId: id, active: true }); + } + return result; +} + +export function getRetryQueueSize(): number { + return retryQueue.length; +} diff --git a/backend-node/src/services/collector/protocols/modbusClient.ts b/backend-node/src/services/collector/protocols/modbusClient.ts new file mode 100644 index 00000000..efd4b96a --- /dev/null +++ b/backend-node/src/services/collector/protocols/modbusClient.ts @@ -0,0 +1,283 @@ +/** + * Modbus TCP Client + * - 순수 TCP 소켓 기반 (외부 의존성 없음) + * - Python data-collector의 modbus_collector.py 포팅 + * + * Modbus TCP 프레임: + * [0-1] Transaction ID + * [2-3] Protocol ID (0x0000) + * [4-5] Length (Unit ID + PDU) + * [6] Unit ID (slave) + * [7] Function Code (0x03=Holding, 0x04=Input) + * [8-9] Start Address + * [10-11] Quantity + */ + +import net from "net"; +import { logger } from "../../../utils/logger"; + +// ─── 타입 ────────────────────────────────────────── + +export interface ModbusReadResult { + tagName: string; + address: string; + rawValue: number; + value: number | boolean; + quality: "good" | "bad"; + timestamp: Date; +} + +export interface ModbusTagConfig { + tagName: string; + address: string; // 예: HR100, IR200 (Holding Register 100, Input Register 200) + dataType: "UINT16" | "INT16" | "UINT32" | "INT32" | "FLOAT32" | "FLOAT64" | "BOOLEAN"; + byteOrder?: "BIG_ENDIAN" | "LITTLE_ENDIAN" | "BIG_ENDIAN_SWAP" | "LITTLE_ENDIAN_SWAP"; + scaleFactor?: number; + offsetValue?: number; +} + +// ─── 주소 파싱 ──────────────────────────────────── + +function parseModbusAddress(address: string): { functionCode: number; register: number } { + const match = address.match(/^(HR|IR|CO|DI)(\d+)$/i); + if (!match) { + // 숫자만 오면 Holding Register로 간주 + const numMatch = address.match(/^(\d+)$/); + if (numMatch) return { functionCode: 0x03, register: parseInt(numMatch[1], 10) }; + throw new Error(`잘못된 Modbus 주소: ${address}`); + } + + const [, prefix, numStr] = match; + const register = parseInt(numStr, 10); + + switch (prefix.toUpperCase()) { + case "HR": return { functionCode: 0x03, register }; // Holding Register (FC03) + case "IR": return { functionCode: 0x04, register }; // Input Register (FC04) + case "CO": return { functionCode: 0x01, register }; // Coil (FC01) + case "DI": return { functionCode: 0x02, register }; // Discrete Input (FC02) + default: return { functionCode: 0x03, register }; + } +} + +// ─── 레지스터 수 계산 ───────────────────────────── + +function getRegisterCount(dataType: string): number { + switch (dataType) { + case "BOOLEAN": + case "UINT16": + case "INT16": + return 1; + case "UINT32": + case "INT32": + case "FLOAT32": + return 2; + case "FLOAT64": + return 4; + default: + return 1; + } +} + +// ─── Modbus TCP 프레임 빌더 ─────────────────────── + +function buildReadRequest(transactionId: number, unitId: number, functionCode: number, startAddr: number, quantity: number): Buffer { + const buf = Buffer.alloc(12); + + // MBAP Header + buf.writeUInt16BE(transactionId, 0); // Transaction ID + buf.writeUInt16BE(0x0000, 2); // Protocol ID + buf.writeUInt16BE(6, 4); // Length (Unit + FC + Addr + Qty) + buf.writeUInt8(unitId, 6); // Unit ID + + // PDU + buf.writeUInt8(functionCode, 7); // Function Code + buf.writeUInt16BE(startAddr, 8); // Start Address + buf.writeUInt16BE(quantity, 10); // Quantity + + return buf; +} + +// ─── 응답 파싱 ──────────────────────────────────── + +function parseReadResponse(response: Buffer, expectedTxId: number): number[] { + if (response.length < 9) throw new Error("응답이 너무 짧음"); + + const txId = response.readUInt16BE(0); + if (txId !== expectedTxId) throw new Error(`Transaction ID 불일치: ${txId} != ${expectedTxId}`); + + const fc = response.readUInt8(7); + // Error response (FC | 0x80) + if (fc & 0x80) { + const errorCode = response.readUInt8(8); + throw new Error(`Modbus 에러 FC=0x${fc.toString(16)} Code=${errorCode}`); + } + + const byteCount = response.readUInt8(8); + const registers: number[] = []; + + for (let i = 0; i < byteCount / 2; i++) { + if (9 + i * 2 + 2 <= response.length) { + registers.push(response.readUInt16BE(9 + i * 2)); + } + } + + return registers; +} + +// ─── 데이터 타입 변환 ───────────────────────────── + +function convertValue(registers: number[], dataType: string, byteOrder: string = "BIG_ENDIAN"): number | boolean { + if (registers.length === 0) return 0; + + if (dataType === "BOOLEAN") return registers[0] !== 0; + if (dataType === "UINT16") return registers[0]; + if (dataType === "INT16") { + const v = registers[0]; + return v >= 0x8000 ? v - 0x10000 : v; + } + + // 32bit: byte order 처리 + if (registers.length >= 2 && (dataType === "UINT32" || dataType === "INT32" || dataType === "FLOAT32")) { + let r0 = registers[0], r1 = registers[1]; + + // Byte order swap + if (byteOrder === "LITTLE_ENDIAN" || byteOrder === "LITTLE_ENDIAN_SWAP") { + [r0, r1] = [r1, r0]; + } + + const buf = Buffer.alloc(4); + if (byteOrder === "BIG_ENDIAN_SWAP" || byteOrder === "LITTLE_ENDIAN_SWAP") { + // Word swap: ABCD → CDAB + buf.writeUInt16BE(r1, 0); + buf.writeUInt16BE(r0, 2); + } else { + buf.writeUInt16BE(r0, 0); + buf.writeUInt16BE(r1, 2); + } + + if (dataType === "FLOAT32") return buf.readFloatBE(0); + if (dataType === "UINT32") return buf.readUInt32BE(0); + if (dataType === "INT32") return buf.readInt32BE(0); + } + + return registers[0]; +} + +// ─── Modbus TCP 클라이언트 ──────────────────────── + +export class ModbusClient { + private host: string; + private port: number; + private unitId: number; + private timeout: number; + private socket: net.Socket | null = null; + private connected = false; + private transactionId = 0; + + constructor(host: string, port: number = 502, unitId: number = 1, timeout: number = 3000) { + this.host = host; + this.port = port; + this.unitId = unitId; + this.timeout = timeout; + } + + async connect(): Promise { + if (this.connected && this.socket) return; + + return new Promise((resolve, reject) => { + this.socket = new net.Socket(); + this.socket.setTimeout(this.timeout); + + this.socket.connect(this.port, this.host, () => { + this.connected = true; + logger.info(`[Modbus] 연결 성공: ${this.host}:${this.port} (Unit ${this.unitId})`); + resolve(); + }); + + this.socket.on("error", (err) => { + this.connected = false; + reject(new Error(`[Modbus] 연결 실패: ${err.message}`)); + }); + + this.socket.on("timeout", () => { + this.socket?.destroy(); + this.connected = false; + reject(new Error(`[Modbus] 타임아웃: ${this.timeout}ms`)); + }); + + this.socket.on("close", () => { this.connected = false; }); + }); + } + + disconnect(): void { + if (this.socket) { + this.socket.destroy(); + this.socket = null; + this.connected = false; + } + } + + isConnected(): boolean { return this.connected; } + + private async rawRead(functionCode: number, startAddr: number, quantity: number): Promise { + if (!this.socket || !this.connected) throw new Error("[Modbus] 연결되지 않음"); + + const txId = this.transactionId++ & 0xffff; + const frame = buildReadRequest(txId, this.unitId, functionCode, startAddr, quantity); + + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let resolved = false; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + this.socket?.removeAllListeners("data"); + reject(new Error("[Modbus] 읽기 타임아웃")); + } + }, this.timeout); + + const onData = (data: Buffer) => { + chunks.push(data); + const response = Buffer.concat(chunks); + if (response.length >= 9 + quantity * 2) { + clearTimeout(timeout); + resolved = true; + this.socket?.removeListener("data", onData); + try { + resolve(parseReadResponse(response, txId)); + } catch (e) { reject(e); } + } + }; + + this.socket!.on("data", onData); + this.socket!.write(frame); + }); + } + + async readTags(tags: ModbusTagConfig[]): Promise { + const results: ModbusReadResult[] = []; + + for (const tag of tags) { + const now = new Date(); + try { + const { functionCode, register } = parseModbusAddress(tag.address); + const count = getRegisterCount(tag.dataType); + const registers = await this.rawRead(functionCode, register, count); + const rawValue = registers[0] ?? 0; + let value = convertValue(registers, tag.dataType, tag.byteOrder); + + if (typeof value === "number" && tag.scaleFactor !== undefined && tag.scaleFactor !== 1) { + value = value * tag.scaleFactor + (tag.offsetValue ?? 0); + } + + results.push({ tagName: tag.tagName, address: tag.address, rawValue, value, quality: "good", timestamp: now }); + } catch (err) { + logger.warn(`[Modbus] 태그 읽기 실패: ${tag.tagName} (${tag.address}) - ${(err as Error).message}`); + results.push({ tagName: tag.tagName, address: tag.address, rawValue: 0, value: 0, quality: "bad", timestamp: now }); + } + } + + return results; + } +} diff --git a/backend-node/src/services/collector/protocols/xgtClient.ts b/backend-node/src/services/collector/protocols/xgtClient.ts new file mode 100644 index 00000000..28ba3618 --- /dev/null +++ b/backend-node/src/services/collector/protocols/xgtClient.ts @@ -0,0 +1,392 @@ +/** + * LS XGT FEnet Protocol Client + * - LS Electric PLC (XGK/XGI/XGR) 통신 + * - Python data-collector의 xgt_collector.py 포팅 + * + * 프레임 구조 (20byte header + application data): + * [0-3] Company ID: "LSIS" (0x4C534953) + * [4-5] Reserved + * [6-7] PLC Info + * [8] CPU Info: 0xA0 (XGK) + * [9] Source: 0x33 (PC→PLC) + * [10-11] Invoke ID + * [12-13] Data Length (little-endian) + * [14] Station No (0x00) + * [15] Network No (0x00) + * [16-17] Data Length repeated + * [18-19] Reserved + * + * Command: 0x0054 = Read, 0x0058 = Write + */ + +import net from "net"; +import { logger } from "../../../utils/logger"; + +// ─── 타입 ────────────────────────────────────────── + +export interface XgtReadResult { + tagName: string; + address: string; + rawValue: number; + value: number | boolean | string; + quality: "good" | "bad"; + timestamp: Date; +} + +export interface XgtTagConfig { + tagName: string; + address: string; // 예: D100, M0, K100 + dataType: "BOOL" | "INT16" | "UINT16" | "INT32" | "UINT32" | "FLOAT32"; + bitIndex?: number; // BOOL일 때 비트 위치 (0-15) + scaleFactor?: number; + offsetValue?: number; +} + +// ─── XGT 메모리 영역 ────────────────────────────── + +const MEMORY_TYPES: Record = { + P: "%PW", // Input + M: "%MW", // Auxiliary relay + K: "%KW", // Keep relay + L: "%LW", // Link relay + F: "%FW", // Special relay + T: "%TW", // Timer + C: "%CW", // Counter + D: "%DW", // Data register + R: "%RW", // Retain +}; + +// ─── 주소 파싱 ──────────────────────────────────── + +function parseAddress(address: string): { memType: string; xgtAddress: string; offset: number } { + // D100, M0, K100, D100.5 (bit) 등 파싱 + const match = address.match(/^([A-Z])(\d+)(?:\.(\d+))?$/); + if (!match) throw new Error(`잘못된 XGT 주소: ${address}`); + + const [, memLetter, numStr] = match; + const num = parseInt(numStr, 10); + const prefix = MEMORY_TYPES[memLetter]; + if (!prefix) throw new Error(`지원하지 않는 메모리 영역: ${memLetter}`); + + // XGT는 %DW 뒤에 주소를 바이트 오프셋이 아닌 워드 번호로 씀 + // D100 → %DW100 (워드 100번) + const xgtAddress = `${prefix}${String(num).padStart(5, "0")}`; + return { memType: memLetter, xgtAddress, offset: num }; +} + +// ─── XGT FEnet 프레임 빌더 ──────────────────────── + +function buildReadFrame(xgtAddress: string, wordCount: number, invokeId: number = 0): Buffer { + // Application data + const addrBytes = Buffer.from(xgtAddress, "ascii"); + const addrLen = addrBytes.length; + + // App data: command(2) + dataType(2) + reserved(2) + blockCount(2) + addrLen(2) + addr + readCount(2) + const appDataLen = 2 + 2 + 2 + 2 + 2 + addrLen + 2; + const appData = Buffer.alloc(appDataLen); + let offset = 0; + + // Command: 0x0054 = Read request + appData.writeUInt16LE(0x0054, offset); offset += 2; + // Data type: 0x0014 = Word (continuous) + appData.writeUInt16LE(0x0014, offset); offset += 2; + // Reserved + appData.writeUInt16LE(0x0000, offset); offset += 2; + // Block count: 1 + appData.writeUInt16LE(0x0001, offset); offset += 2; + // Address string length + appData.writeUInt16LE(addrLen, offset); offset += 2; + // Address string + addrBytes.copy(appData, offset); offset += addrLen; + // Read count (words) + appData.writeUInt16LE(wordCount, offset); offset += 2; + + // Header (20 bytes) + const header = Buffer.alloc(20); + // Company ID: "LSIS" + header.write("LSIS", 0, 4, "ascii"); + // Reserved (4-5) + header.writeUInt16LE(0x0000, 4); + // PLC Info (6-7) + header.writeUInt16LE(0x0000, 6); + // CPU Info: XGK + header.writeUInt8(0xa0, 8); + // Source: 0x33 (PC → PLC) + header.writeUInt8(0x33, 9); + // Invoke ID + header.writeUInt16LE(invokeId & 0xffff, 10); + // Data length + header.writeUInt16LE(appDataLen, 12); + // Station No + header.writeUInt8(0x00, 14); + // Network No + header.writeUInt8(0x00, 15); + // Data length (repeated) + header.writeUInt16LE(appDataLen, 16); + // Reserved + header.writeUInt16LE(0x0000, 18); + + return Buffer.concat([header, appData]); +} + +// ─── 응답 파싱 ──────────────────────────────────── + +function parseReadResponse(response: Buffer): number[] { + if (response.length < 20) throw new Error("응답이 너무 짧음"); + + // Header 확인 + const companyId = response.toString("ascii", 0, 4); + if (companyId !== "LSIS") throw new Error(`잘못된 응답 헤더: ${companyId}`); + + // Data length + const dataLen = response.readUInt16LE(12); + if (response.length < 20 + dataLen) throw new Error("응답 데이터 불완전"); + + // Application data 시작: offset 20 + // Response: command(2) + dataType(2) + reserved(2) + errorState(2) + blockCount(2) + dataLen(2) + data... + const appOffset = 20; + + // Error state 확인 + const errorState = response.readUInt16LE(appOffset + 6); + if (errorState !== 0) throw new Error(`PLC 에러 코드: 0x${errorState.toString(16)}`); + + // Block count + const blockCount = response.readUInt16LE(appOffset + 8); + if (blockCount === 0) return []; + + // Data length (bytes) + const wordDataLen = response.readUInt16LE(appOffset + 10); + const wordCount = wordDataLen / 2; + + // Word 데이터 읽기 + const words: number[] = []; + const dataStart = appOffset + 12; + for (let i = 0; i < wordCount; i++) { + if (dataStart + i * 2 + 2 <= response.length) { + words.push(response.readUInt16LE(dataStart + i * 2)); + } + } + + return words; +} + +// ─── 데이터 타입 변환 ───────────────────────────── + +function convertValue(words: number[], dataType: string, bitIndex?: number): number | boolean { + if (words.length === 0) return 0; + + switch (dataType) { + case "BOOL": { + const bit = bitIndex ?? 0; + return Boolean((words[0] >> bit) & 1); + } + case "UINT16": + return words[0]; + case "INT16": { + const v = words[0]; + return v >= 0x8000 ? v - 0x10000 : v; + } + case "UINT32": { + if (words.length < 2) return words[0]; + return (words[1] << 16) | words[0]; // little-endian + } + case "INT32": { + if (words.length < 2) return words[0]; + const v = (words[1] << 16) | words[0]; + return v >= 0x80000000 ? v - 0x100000000 : v; + } + case "FLOAT32": { + if (words.length < 2) return 0; + const buf = Buffer.alloc(4); + buf.writeUInt16LE(words[0], 0); + buf.writeUInt16LE(words[1], 2); + return buf.readFloatLE(0); + } + default: + return words[0]; + } +} + +// ─── 워드 수 계산 ───────────────────────────────── + +function getWordCount(dataType: string): number { + switch (dataType) { + case "BOOL": + case "UINT16": + case "INT16": + return 1; + case "UINT32": + case "INT32": + case "FLOAT32": + return 2; + case "FLOAT64": + return 4; + default: + return 1; + } +} + +// ─── XGT 클라이언트 클래스 ──────────────────────── + +export class XgtClient { + private host: string; + private port: number; + private timeout: number; + private socket: net.Socket | null = null; + private connected = false; + private invokeId = 0; + + constructor(host: string, port: number = 2004, timeout: number = 3000) { + this.host = host; + this.port = port; + this.timeout = timeout; + } + + async connect(): Promise { + if (this.connected && this.socket) return; + + return new Promise((resolve, reject) => { + this.socket = new net.Socket(); + this.socket.setTimeout(this.timeout); + + this.socket.connect(this.port, this.host, () => { + this.connected = true; + logger.info(`[XGT] PLC 연결 성공: ${this.host}:${this.port}`); + resolve(); + }); + + this.socket.on("error", (err) => { + this.connected = false; + reject(new Error(`[XGT] 연결 실패: ${err.message}`)); + }); + + this.socket.on("timeout", () => { + this.socket?.destroy(); + this.connected = false; + reject(new Error(`[XGT] 연결 타임아웃: ${this.timeout}ms`)); + }); + + this.socket.on("close", () => { + this.connected = false; + }); + }); + } + + disconnect(): void { + if (this.socket) { + this.socket.destroy(); + this.socket = null; + this.connected = false; + } + } + + isConnected(): boolean { + return this.connected; + } + + // 단일 주소 읽기 + private async rawRead(xgtAddress: string, wordCount: number): Promise { + if (!this.socket || !this.connected) throw new Error("[XGT] 연결되지 않음"); + + const frame = buildReadFrame(xgtAddress, wordCount, this.invokeId++); + + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let resolved = false; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + this.socket?.removeAllListeners("data"); + reject(new Error(`[XGT] 읽기 타임아웃: ${xgtAddress}`)); + } + }, this.timeout); + + const onData = (data: Buffer) => { + chunks.push(data); + const response = Buffer.concat(chunks); + + // 헤더(20) + 최소 응답 데이터(12) 이상 받았는지 확인 + if (response.length >= 32) { + clearTimeout(timeout); + resolved = true; + this.socket?.removeListener("data", onData); + try { + const words = parseReadResponse(response); + resolve(words); + } catch (e) { + reject(e); + } + } + }; + + this.socket!.on("data", onData); + this.socket!.write(frame); + }); + } + + // 태그 배열 읽기 (배치 최적화) + async readTags(tags: XgtTagConfig[]): Promise { + const results: XgtReadResult[] = []; + + // 메모리 영역별 그루핑 + const groups = new Map(); + for (const tag of tags) { + const { memType } = parseAddress(tag.address); + const key = memType; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(tag); + } + + // 그룹별 읽기 + for (const [, groupTags] of groups) { + for (const tag of groupTags) { + const now = new Date(); + try { + const { xgtAddress } = parseAddress(tag.address); + const wordCount = getWordCount(tag.dataType); + const words = await this.rawRead(xgtAddress, wordCount); + const rawValue = words[0] ?? 0; + let value = convertValue(words, tag.dataType, tag.bitIndex); + + // 스케일링 적용 + if (typeof value === "number" && tag.scaleFactor !== undefined && tag.scaleFactor !== 1) { + value = value * tag.scaleFactor + (tag.offsetValue ?? 0); + } + + results.push({ tagName: tag.tagName, address: tag.address, rawValue, value, quality: "good", timestamp: now }); + } catch (err) { + logger.warn(`[XGT] 태그 읽기 실패: ${tag.tagName} (${tag.address}) - ${(err as Error).message}`); + results.push({ tagName: tag.tagName, address: tag.address, rawValue: 0, value: 0, quality: "bad", timestamp: now }); + } + } + } + + return results; + } + + // 단일 태그 읽기 (간편 API) + async readTag(tag: XgtTagConfig): Promise { + const results = await this.readTags([tag]); + return results[0]; + } +} + +// ─── 커넥션 풀 (같은 IP:Port 재사용) ────────────── + +const connectionPool = new Map(); + +export function getXgtClient(host: string, port: number = 2004, timeout: number = 3000): XgtClient { + const key = `${host}:${port}`; + if (!connectionPool.has(key)) { + connectionPool.set(key, new XgtClient(host, port, timeout)); + } + return connectionPool.get(key)!; +} + +export function closeAllXgtConnections(): void { + for (const [key, client] of connectionPool) { + client.disconnect(); + connectionPool.delete(key); + } +} diff --git a/backend-node/src/services/llmClient.ts b/backend-node/src/services/llmClient.ts new file mode 100644 index 00000000..8f589c54 --- /dev/null +++ b/backend-node/src/services/llmClient.ts @@ -0,0 +1,390 @@ +/** + * 자체 LLM 클라이언트 + * DB에 등록된 프로바이더 설정(API 키, 엔드포인트)을 읽어 직접 호출 + * + * 지원 프로바이더: + * - anthropic → Anthropic Messages API + * - openai → OpenAI Chat Completions API + * - google → Gemini OpenAI-compatible API + * - deepseek → DeepSeek (OpenAI-compatible) + * - ollama → Ollama (OpenAI-compatible, 로컬) + */ +import axios, { AxiosResponse } from "axios"; +import { Readable } from "stream"; +import { query } from "../database/db"; +import { AiLlmProvider } from "../types/aiAgent"; +import { EncryptUtil } from "../utils/encryptUtil"; +import { logger } from "../utils/logger"; + +// ── 타입 ────────────────────────────────────────── + +export interface ChatMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +/** OpenAI 호환 응답 형식 (내부 표준) */ +export interface ChatCompletionResponse { + id: string; + object: "chat.completion"; + model: string; + choices: Array<{ + index: number; + message: { role: "assistant"; content: string }; + finish_reason: string; + }>; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +export interface LlmRequestParams { + model: string; + messages: ChatMessage[]; + max_tokens?: number; + temperature?: number; + stream?: boolean; + /** 특정 프로바이더 ID를 직접 지정 (모델명 자동매칭 대신) */ + provider_id?: number; +} + +// ── 프로바이더별 기본 엔드포인트 ────────────────── + +const DEFAULT_ENDPOINTS: Record = { + anthropic: "https://api.anthropic.com", + openai: "https://api.openai.com", + google: "https://generativelanguage.googleapis.com/v1beta/openai", // OpenAI-compatible + deepseek: "https://api.deepseek.com", + ollama: "http://localhost:11434", +}; + +// 모델명 → 프로바이더 매핑 (프리픽스) +const MODEL_PROVIDER_MAP: Array<[RegExp, string]> = [ + [/^claude-/, "anthropic"], + [/^gpt-|^o[1-9]|^o3/, "openai"], + [/^gemini-/, "google"], + [/^deepseek-/, "deepseek"], + [/^llama|^mistral|^codellama|^phi|^qwen/, "ollama"], +]; + +// ── 메인 클라이언트 ────────────────────────────── + +export class LlmClient { + /** + * 프로바이더를 DB에서 resolve + * 1) provider_id 직접 지정 → 해당 프로바이더 + * 2) 모델명으로 프로바이더 이름 추론 → DB에서 해당 프로바이더 조회 + * 3) 매칭 실패 → 우선순위 가장 높은 활성 프로바이더 + */ + static async resolveProvider( + model: string, + providerId?: number + ): Promise<{ provider: AiLlmProvider; apiKey: string }> { + let provider: AiLlmProvider | undefined; + + if (providerId) { + const rows = await query( + "SELECT * FROM ai_llm_providers WHERE id = $1 AND is_active = true", + [providerId] + ); + provider = rows[0]; + } + + if (!provider) { + // 모델명으로 프로바이더 이름 추론 + let providerName: string | null = null; + for (const [re, name] of MODEL_PROVIDER_MAP) { + if (re.test(model)) { providerName = name; break; } + } + + if (providerName) { + const rows = await query( + "SELECT * FROM ai_llm_providers WHERE name = $1 AND is_active = true ORDER BY priority LIMIT 1", + [providerName] + ); + provider = rows[0]; + } + } + + if (!provider) { + // 폴백: 우선순위 가장 높은 활성 프로바이더 + const rows = await query( + "SELECT * FROM ai_llm_providers WHERE is_active = true ORDER BY priority LIMIT 1" + ); + provider = rows[0]; + } + + if (!provider) { + throw new Error("활성화된 LLM 프로바이더가 없습니다. 관리자 설정에서 프로바이더를 등록해주세요."); + } + + const apiKey = EncryptUtil.decrypt(provider.api_key_encrypted); + return { provider, apiKey }; + } + + /** + * 채팅 완성 (비스트리밍) + */ + static async chatCompletion(params: LlmRequestParams): Promise { + const { provider, apiKey } = await this.resolveProvider(params.model, params.provider_id); + const model = params.model || provider.model_name; + + logger.info(`[LLM] ${provider.name}/${model} 호출 (messages: ${params.messages.length})`); + + if (provider.name === "anthropic") { + return this.callAnthropic(provider, apiKey, model, params); + } + + // OpenAI / Google / DeepSeek / Ollama → 모두 OpenAI-compatible + return this.callOpenAICompatible(provider, apiKey, model, params); + } + + /** + * 채팅 완성 (스트리밍) → Readable stream 반환 (SSE 형식) + */ + static async chatCompletionStream(params: LlmRequestParams): Promise { + const { provider, apiKey } = await this.resolveProvider(params.model, params.provider_id); + const model = params.model || provider.model_name; + + logger.info(`[LLM] ${provider.name}/${model} 스트리밍 호출`); + + if (provider.name === "anthropic") { + return this.streamAnthropic(provider, apiKey, model, params); + } + + return this.streamOpenAICompatible(provider, apiKey, model, params); + } + + /** + * 사용 가능한 모델 목록 (DB 기반) + */ + static async listModels(): Promise { + const providers = await query( + "SELECT * FROM ai_llm_providers WHERE is_active = true ORDER BY priority" + ); + + const models = providers.map((p) => ({ + id: p.model_name, + object: "model", + owned_by: p.name, + display_name: p.display_name, + })); + + return { object: "list", data: models }; + } + + // ── Anthropic Messages API ────────────────────── + + private static async callAnthropic( + provider: AiLlmProvider, + apiKey: string, + model: string, + params: LlmRequestParams + ): Promise { + const baseUrl = provider.endpoint || DEFAULT_ENDPOINTS.anthropic; + + // Anthropic 형식: system은 별도 파라미터 + const systemMsg = params.messages.find((m) => m.role === "system"); + const nonSystemMsgs = params.messages.filter((m) => m.role !== "system"); + + const response = await axios.post( + `${baseUrl}/v1/messages`, + { + model, + system: systemMsg?.content || undefined, + messages: nonSystemMsgs.map((m) => ({ role: m.role, content: m.content })), + max_tokens: params.max_tokens || provider.max_tokens || 4096, + temperature: params.temperature ?? provider.temperature ?? 0.7, + }, + { + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + timeout: 120000, + } + ); + + // Anthropic → OpenAI 형식 변환 + const data = response.data; + return { + id: data.id || `chatcmpl-${Date.now()}`, + object: "chat.completion", + model: data.model || model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: data.content?.map((c: any) => c.text).join("") || "", + }, + finish_reason: data.stop_reason === "end_turn" ? "stop" : (data.stop_reason || "stop"), + }, + ], + usage: { + prompt_tokens: data.usage?.input_tokens || 0, + completion_tokens: data.usage?.output_tokens || 0, + total_tokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0), + }, + }; + } + + private static async streamAnthropic( + provider: AiLlmProvider, + apiKey: string, + model: string, + params: LlmRequestParams + ): Promise { + const baseUrl = provider.endpoint || DEFAULT_ENDPOINTS.anthropic; + const systemMsg = params.messages.find((m) => m.role === "system"); + const nonSystemMsgs = params.messages.filter((m) => m.role !== "system"); + + const response: AxiosResponse = await axios.post( + `${baseUrl}/v1/messages`, + { + model, + system: systemMsg?.content || undefined, + messages: nonSystemMsgs.map((m) => ({ role: m.role, content: m.content })), + max_tokens: params.max_tokens || provider.max_tokens || 4096, + temperature: params.temperature ?? provider.temperature ?? 0.7, + stream: true, + }, + { + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + timeout: 120000, + responseType: "stream", + } + ); + + // Anthropic SSE → OpenAI SSE 변환 스트림 + const transform = new Readable({ read() {} }); + let buffer = ""; + + response.data.on("data", (chunk: Buffer) => { + buffer += chunk.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6).trim(); + if (payload === "[DONE]") { + transform.push("data: [DONE]\n\n"); + return; + } + + try { + const event = JSON.parse(payload); + if (event.type === "content_block_delta" && event.delta?.text) { + const openaiChunk = { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + model, + choices: [{ index: 0, delta: { content: event.delta.text }, finish_reason: null }], + }; + transform.push(`data: ${JSON.stringify(openaiChunk)}\n\n`); + } else if (event.type === "message_stop") { + const stopChunk = { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }; + transform.push(`data: ${JSON.stringify(stopChunk)}\n\n`); + transform.push("data: [DONE]\n\n"); + } + } catch { /* 파싱 실패 무시 */ } + } + }); + + response.data.on("end", () => { transform.push(null); }); + response.data.on("error", (err: Error) => { transform.destroy(err); }); + + return transform; + } + + // ── OpenAI-compatible (OpenAI, Google, DeepSeek, Ollama) ── + + private static getOpenAIBaseUrl(provider: AiLlmProvider): string { + if (provider.endpoint) return provider.endpoint; + return DEFAULT_ENDPOINTS[provider.name] || DEFAULT_ENDPOINTS.openai; + } + + private static getOpenAIChatUrl(provider: AiLlmProvider): string { + const base = this.getOpenAIBaseUrl(provider); + + // Google Gemini OpenAI-compatible 엔드포인트 + if (provider.name === "google") { + return `${base}/chat/completions`; + } + + // Ollama, DeepSeek, OpenAI → /v1/chat/completions + return `${base}/v1/chat/completions`; + } + + private static async callOpenAICompatible( + provider: AiLlmProvider, + apiKey: string, + model: string, + params: LlmRequestParams + ): Promise { + const url = this.getOpenAIChatUrl(provider); + + const response = await axios.post( + url, + { + model, + messages: params.messages, + max_tokens: params.max_tokens || provider.max_tokens || 4096, + temperature: params.temperature ?? provider.temperature ?? 0.7, + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + timeout: 120000, + } + ); + + return response.data; + } + + private static async streamOpenAICompatible( + provider: AiLlmProvider, + apiKey: string, + model: string, + params: LlmRequestParams + ): Promise { + const url = this.getOpenAIChatUrl(provider); + + const response: AxiosResponse = await axios.post( + url, + { + model, + messages: params.messages, + max_tokens: params.max_tokens || provider.max_tokens || 4096, + temperature: params.temperature ?? provider.temperature ?? 0.7, + stream: true, + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + timeout: 120000, + responseType: "stream", + } + ); + + // OpenAI-compatible → 그대로 전달 + return response.data; + } +} diff --git a/backend-node/src/services/multiAgentExecutionEngine.ts b/backend-node/src/services/multiAgentExecutionEngine.ts new file mode 100644 index 00000000..9ab9488c --- /dev/null +++ b/backend-node/src/services/multiAgentExecutionEngine.ts @@ -0,0 +1,478 @@ +import { query, queryOne } from "../database/db"; +import { AiAgentGroupService, GroupMember, ConnectorRef } from "./aiAgentGroupService"; +import { AiAgentUsageService } from "./aiAgentUsageService"; +import { AiAgentConversationService } from "./aiAgentConversationService"; +import { AiAnalysisLogService } from "./aiAnalysisLogService"; +import { LlmClient } from "./llmClient"; +import { logger } from "../utils/logger"; + +interface ExecutionResult { + memberId: number; + roleName: string; + agentName: string; + modelName: string; + executionOrder: number; + response: string; + tokensUsed: number; + durationMs: number; + connectorResults?: any[]; +} + +interface GroupExecutionResult { + groupId: number; + groupName: string; + executionMode: string; + steps: ExecutionResult[]; + finalSummary: string; + totalTokens: number; + totalDurationMs: number; +} + +/** + * 멀티 에이전트 실행 엔진 + * - sequential: 1→2→3 순차 실행, 이전 결과를 다음에 전달 + * - parallel: 전체 동시 실행, 결과 취합 + * - mixed: execution_order 같으면 병렬, 다르면 순차 + */ +export class MultiAgentExecutionEngine { + /** + * 멀티 에이전트 그룹 실행 + */ + static async execute( + groupId: number, + userMessage: string, + options?: { userId?: string; apiKeyId?: number } + ): Promise { + const group = await AiAgentGroupService.getById(groupId); + if (!group) throw new Error("멀티 에이전트 그룹을 찾을 수 없습니다."); + if (!group.members || group.members.length === 0) throw new Error("그룹에 에이전트가 없습니다."); + + const executionMode = (group as any).execution_mode || "mixed"; + const startTime = Date.now(); + let allResults: ExecutionResult[] = []; + + logger.info(`멀티 에이전트 실행 시작: ${group.name} (${executionMode}) - "${userMessage.substring(0, 50)}..."`); + + // 과거 분석 이력 조회 (에이전트 컨텍스트에 추가) + let historyContext = ""; + try { + const recentLogs = await AiAnalysisLogService.getRecentLogs(groupId, 30, 5); + if (recentLogs.length > 0) { + historyContext = "\n[과거 분석 이력 (최근 5건)]:\n" + + recentLogs.map((log: any) => + `- ${new Date(log.created_at).toLocaleDateString("ko")}: ${log.analysis_result.substring(0, 200)}...` + ).join("\n"); + } + const accuracy = await AiAnalysisLogService.getAverageAccuracy(groupId); + if (accuracy > 0) { + historyContext += `\n평균 예측 정확도: ${accuracy.toFixed(1)}%`; + } + } catch { /* 이력 없으면 무시 */ } + + // 이력 컨텍스트를 메시지에 추가 + const enrichedMessage = historyContext + ? `${userMessage}\n\n${historyContext}` + : userMessage; + + if (executionMode === "parallel") { + allResults = await this.executeParallel(group.members, enrichedMessage, ""); + } else if (executionMode === "sequential") { + allResults = await this.executeSequential(group.members, enrichedMessage); + } else { + allResults = await this.executeMixed(group.members, enrichedMessage); + } + + // 최종 요약 생성 + const finalSummary = this.buildFinalSummary(allResults, userMessage); + const totalTokens = allResults.reduce((sum, r) => sum + r.tokensUsed, 0); + const totalDuration = Date.now() - startTime; + + // 대화 기록 저장 (에이전트 간 대화 모니터링용) + try { + const conv = await AiAgentConversationService.createConversation( + undefined, + options?.userId, + options?.apiKeyId + ); + // 대화 제목 설정 + await query( + "UPDATE ai_agent_conversations SET title = $1, metadata = $2 WHERE id = $3", + [ + `[${group.name}] ${userMessage.substring(0, 100)}`, + JSON.stringify({ group_id: groupId, group_name: group.name, execution_mode: executionMode }), + conv.id, + ] + ); + // 사용자 메시지 저장 + await AiAgentConversationService.addMessage(conv.id, "user", userMessage, 0); + // 각 에이전트 스텝별 응답 저장 + for (const step of allResults) { + await AiAgentConversationService.addMessage( + conv.id, + "assistant", + `[${step.roleName} - ${step.agentName}]\n${step.response}`, + step.tokensUsed, + { role_name: step.roleName, agent_name: step.agentName, model_name: step.modelName, execution_order: step.executionOrder, duration_ms: step.durationMs } + ); + } + logger.info(`멀티 에이전트 대화 저장 완료: conv_id=${conv.id}`); + } catch (e) { + logger.warn("멀티 에이전트 대화 저장 실패:", e); + } + + // 분석 이력 저장 (예측 진화용) + await AiAnalysisLogService.save({ + group_id: groupId, + execution_type: options?.apiKeyId ? "api" : "manual", + input_message: userMessage, + analysis_result: finalSummary, + tokens_used: totalTokens, + duration_ms: totalDuration, + }).catch((e) => logger.warn("분석 이력 저장 실패:", e)); + + // 사용량 로깅 + await AiAgentUsageService.log({ + user_id: options?.userId, + api_key_id: options?.apiKeyId, + total_tokens: totalTokens, + response_time_ms: totalDuration, + success: true, + request_path: `/groups/${groupId}`, + }); + + logger.info(`멀티 에이전트 실행 완료: ${group.name} - ${totalTokens} tokens, ${totalDuration}ms`); + + return { + groupId: group.id, + groupName: group.name, + executionMode, + steps: allResults, + finalSummary, + totalTokens, + totalDurationMs: totalDuration, + }; + } + + /** + * 순차 실행: 1→2→3, 이전 결과를 다음에 전달 + */ + private static async executeSequential( + members: GroupMember[], + userMessage: string + ): Promise { + const sorted = [...members].sort((a, b) => a.execution_order - b.execution_order); + const results: ExecutionResult[] = []; + let previousContext = ""; + + for (const member of sorted) { + const result = await this.executeSingleAgent(member, userMessage, previousContext); + results.push(result); + previousContext += `\n[${member.role_name} 결과]:\n${result.response}\n`; + } + + return results; + } + + /** + * 병렬 실행: 동시 실행, 결과 취합 + */ + private static async executeParallel( + members: GroupMember[], + userMessage: string, + previousContext: string + ): Promise { + const promises = members.map((member) => + this.executeSingleAgent(member, userMessage, previousContext) + ); + return Promise.all(promises); + } + + /** + * 혼합 실행: execution_order 같으면 병렬, 다르면 순차 + */ + private static async executeMixed( + members: GroupMember[], + userMessage: string + ): Promise { + // execution_order로 그룹핑 + const orderGroups = new Map(); + for (const member of members) { + const order = member.execution_order; + if (!orderGroups.has(order)) orderGroups.set(order, []); + orderGroups.get(order)!.push(member); + } + + const sortedOrders = [...orderGroups.keys()].sort((a, b) => a - b); + const allResults: ExecutionResult[] = []; + let previousContext = ""; + + for (const order of sortedOrders) { + const groupMembers = orderGroups.get(order)!; + + if (groupMembers.length === 1) { + // 단독 → 순차 + const result = await this.executeSingleAgent(groupMembers[0], userMessage, previousContext); + allResults.push(result); + previousContext += `\n[${groupMembers[0].role_name} 결과]:\n${result.response}\n`; + } else { + // 같은 order → 병렬 + const parallelResults = await this.executeParallel(groupMembers, userMessage, previousContext); + allResults.push(...parallelResults); + for (const r of parallelResults) { + previousContext += `\n[${r.roleName} 결과]:\n${r.response}\n`; + } + } + } + + return allResults; + } + + /** + * 단일 에이전트 실행 + */ + private static async executeSingleAgent( + member: GroupMember, + userMessage: string, + previousContext: string + ): Promise { + const startTime = Date.now(); + + // 에이전트 정보 조회 + const agent = await queryOne( + "SELECT * FROM ai_agents WHERE id = $1", + [member.agent_id] + ); + + if (!agent) { + return { + memberId: member.id, + roleName: member.role_name, + agentName: "알 수 없음", + modelName: "unknown", + executionOrder: member.execution_order, + response: "에이전트를 찾을 수 없습니다.", + tokensUsed: 0, + durationMs: Date.now() - startTime, + }; + } + + // 커넥터로 데이터 수집 (MCP 도구 시뮬레이션) + let connectorContext = ""; + const connectorResults: any[] = []; + + for (const connector of (member.connectors || [])) { + try { + const data = await this.executeConnector(connector); + connectorResults.push({ connector: connector.name, type: connector.type, data }); + connectorContext += `\n[데이터 소스: ${connector.name} (${connector.type})]:\n${JSON.stringify(data).substring(0, 2000)}\n`; + } catch (e: any) { + connectorResults.push({ connector: connector.name, type: connector.type, error: e.message }); + connectorContext += `\n[데이터 소스: ${connector.name}]: 조회 실패 - ${e.message}\n`; + } + } + + // 지식 파일 주입 (커스텀 업로드 + 라이브러리 파일) + let knowledgeContext = ""; + const knowledgeFiles = agent.config?.knowledge_files; + if (knowledgeFiles && Array.isArray(knowledgeFiles) && knowledgeFiles.length > 0) { + const resolvedFiles: Array<{ name: string; content: string }> = []; + + for (const f of knowledgeFiles) { + if (f.library_id) { + // 라이브러리 파일: DB에서 최신 내용 조회 + const libFile = await queryOne( + "SELECT name, content FROM ai_knowledge_files WHERE id = $1", + [f.library_id] + ); + if (libFile) resolvedFiles.push({ name: libFile.name, content: libFile.content }); + } else if (f.content) { + // 커스텀 업로드: 저장된 내용 그대로 사용 + resolvedFiles.push({ name: f.name, content: f.content }); + } + } + + if (resolvedFiles.length > 0) { + knowledgeContext = "\n[참고 지식 문서]:\n" + + resolvedFiles.map((f) => `--- ${f.name} ---\n${f.content.substring(0, 10000)}`).join("\n\n"); + } + } + + // LLM 호출 + const systemPrompt = [ + agent.system_prompt || "당신은 도움이 되는 AI 어시스턴트입니다.", + `\n당신의 역할: ${member.role_name}`, + knowledgeContext, + connectorContext ? `\n사용 가능한 데이터:\n${connectorContext}` : "", + previousContext ? `\n이전 에이전트들의 분석 결과:\n${previousContext}` : "", + ].join(""); + + try { + const result = await LlmClient.chatCompletion({ + model: agent.model, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userMessage }, + ], + max_tokens: agent.config?.max_tokens || 2000, + temperature: agent.config?.temperature || 0.7, + }); + + const choice = result.choices?.[0]; + const usage = result.usage; + + return { + memberId: member.id, + roleName: member.role_name, + agentName: agent.name, + modelName: agent.model, + executionOrder: member.execution_order, + response: choice?.message?.content || "응답 없음", + tokensUsed: usage?.total_tokens || 0, + durationMs: Date.now() - startTime, + connectorResults, + }; + } catch (e: any) { + const errDetail = e.response?.data + ? JSON.stringify(e.response.data, null, 2) + : e.message; + logger.warn(`에이전트 실행 실패 (${member.role_name}): ${errDetail}`); + return { + memberId: member.id, + roleName: member.role_name, + agentName: agent.name, + modelName: agent.model, + executionOrder: member.execution_order, + response: `[실행 실패] ${errDetail}`, + tokensUsed: 0, + durationMs: Date.now() - startTime, + connectorResults, + }; + } + } + + /** + * 커넥터 실행 (DB 쿼리, REST API 호출 등) + */ + private static async executeConnector(connector: ConnectorRef): Promise { + if (connector.type === "database" && connector.connection_id) { + // 외부 DB 커넥션으로 샘플 데이터 조회 + const conn = await queryOne( + "SELECT * FROM external_db_connections WHERE id = $1", + [connector.connection_id] + ); + if (!conn) return { error: "커넥션을 찾을 수 없습니다." }; + + // 커넥션 정보 반환 (실제 쿼리는 MCP 도구에서 수행) + return { + type: "database", + name: conn.connection_name, + db_type: conn.db_type, + database: conn.database_name, + status: conn.status, + info: `${conn.db_type} 데이터베이스 (${conn.host}:${conn.port}/${conn.database_name}) 연결 가능`, + }; + } + + if (connector.type === "rest_api" && connector.connection_id) { + const conn = await queryOne( + "SELECT * FROM external_rest_api_connections WHERE id = $1", + [connector.connection_id] + ); + if (!conn) return { error: "커넥션을 찾을 수 없습니다." }; + + return { + type: "rest_api", + name: conn.connection_name, + base_url: conn.base_url, + method: conn.method, + info: `REST API (${conn.method} ${conn.base_url}) 호출 가능`, + }; + } + + if (connector.type === "file" && connector.path) { + try { + const fs = require("fs"); + const path = require("path"); + const filePath = path.resolve(process.cwd(), connector.path); + + if (!fs.existsSync(filePath)) { + return { type: "file", name: connector.name, error: "파일을 찾을 수 없습니다." }; + } + + const ext = path.extname(filePath).toLowerCase(); + const content = fs.readFileSync(filePath, "utf8"); + + if (ext === ".csv") { + // CSV 파싱 (처음 50행) + const lines = content.split("\n").slice(0, 50); + return { type: "file", name: connector.name, format: "csv", rows: lines.length, preview: lines.join("\n") }; + } else if (ext === ".json") { + const data = JSON.parse(content); + return { type: "file", name: connector.name, format: "json", data: JSON.stringify(data).substring(0, 3000) }; + } else { + return { type: "file", name: connector.name, format: ext, preview: content.substring(0, 2000) }; + } + } catch (e: any) { + return { type: "file", name: connector.name, error: e.message }; + } + } + + if (connector.type === "crawler" && connector.config_id) { + try { + // 기존 크롤링 서비스 활용 + const crawlConfig = await queryOne( + "SELECT * FROM crawl_configs WHERE id = $1", + [connector.config_id] + ); + if (!crawlConfig) return { type: "crawler", name: connector.name, error: "크롤링 설정을 찾을 수 없습니다." }; + + return { + type: "crawler", + name: connector.name, + url: crawlConfig.url, + info: `크롤링 대상: ${crawlConfig.url} (${crawlConfig.name})`, + }; + } catch (e: any) { + return { type: "crawler", name: connector.name, error: e.message }; + } + } + + if (connector.type === "plc" && connector.connection_id) { + const conn = await queryOne( + "SELECT * FROM pipeline_device_connections WHERE id = $1", + [connector.connection_id] + ); + if (!conn) return { type: "plc", name: connector.name, error: "장비 연결을 찾을 수 없습니다." }; + + const tags = await query( + "SELECT tag_name, tag_display_name, tag_unit, tag_data_type, address FROM pipeline_tag_mappings WHERE connection_id = $1 AND is_active = 'Y' ORDER BY tag_name", + [connector.connection_id] + ); + + return { + type: "plc", + name: conn.connection_name, + protocol: conn.protocol, + host: conn.host, + port: conn.port, + status: conn.status, + tags: tags.map((t: any) => ({ name: t.tag_name, displayName: t.tag_display_name, unit: t.tag_unit, dataType: t.tag_data_type, address: t.address })), + info: `${conn.protocol} 장비 (${conn.host}:${conn.port}) - ${tags.length}개 태그 수집 중`, + }; + } else if (connector.type === "plc") { + return { type: "plc", name: connector.name, info: "장비 연결 ID가 지정되지 않았습니다." }; + } + + return { type: connector.type, name: connector.name, info: "커넥터 연결 준비됨" }; + } + + /** + * 최종 결과 요약 생성 + */ + private static buildFinalSummary(results: ExecutionResult[], originalQuestion: string): string { + const parts = results.map((r) => + `[${r.roleName} (${r.agentName})]:\n${r.response}` + ); + return `질문: ${originalQuestion}\n\n${parts.join("\n\n---\n\n")}`; + } +} diff --git a/backend-node/src/services/openClawSyncService.ts b/backend-node/src/services/openClawSyncService.ts new file mode 100644 index 00000000..88382bfe --- /dev/null +++ b/backend-node/src/services/openClawSyncService.ts @@ -0,0 +1,141 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; +import { query } from "../database/db"; +import { AiAgent, AiLlmProvider } from "../types/aiAgent"; +import { EncryptUtil } from "../utils/encryptUtil"; +import { logger } from "../utils/logger"; + +const OPENCLAW_CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json"); + +/** + * Pipeline DB → OpenClaw JSON config 동기화 서비스 + * 에이전트/프로바이더 변경 시 OpenClaw config에 반영 + */ +export class OpenClawSyncService { + private static readConfig(): any { + try { + if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) return {}; + const raw = fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf8"); + // JSON5 호환 (주석, trailing comma 허용) + return JSON.parse(raw.replace(/\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1")); + } catch (e) { + logger.warn("OpenClaw config 읽기 실패:", e); + return {}; + } + } + + private static writeConfig(config: any): void { + try { + const dir = path.dirname(OPENCLAW_CONFIG_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8"); + logger.info("OpenClaw config 동기화 완료"); + } catch (e) { + logger.error("OpenClaw config 쓰기 실패:", e); + } + } + + /** + * 프로바이더(LLM API 키)를 OpenClaw auth profiles에 동기화 + */ + static async syncProviders(): Promise { + try { + const providers = await query( + "SELECT * FROM ai_llm_providers WHERE is_active = true ORDER BY priority" + ); + + const config = this.readConfig(); + + // auth profiles 구성 + const authProfiles: Record = {}; + for (const p of providers) { + const decryptedKey = EncryptUtil.decrypt(p.api_key_encrypted); + const profileKey = `pipeline-${p.name}-${p.id}`; + + if (p.name === "anthropic") { + authProfiles[profileKey] = { + provider: "anthropic", + apiKey: decryptedKey, + }; + } else if (p.name === "openai") { + authProfiles[profileKey] = { + provider: "openai", + apiKey: decryptedKey, + }; + } else if (p.name === "google") { + authProfiles[profileKey] = { + provider: "google", + apiKey: decryptedKey, + }; + } else if (p.name === "deepseek") { + authProfiles[profileKey] = { + provider: "openai-compat", + apiKey: decryptedKey, + baseUrl: p.endpoint || "https://api.deepseek.com/v1", + }; + } else if (p.name === "ollama") { + authProfiles[profileKey] = { + provider: "ollama", + baseUrl: p.endpoint || "http://localhost:11434", + }; + } + } + + config.authProfiles = authProfiles; + + // 기본 모델 설정 (우선순위가 가장 높은 프로바이더) + if (providers.length > 0) { + const primary = providers[0]; + const profileKey = `pipeline-${primary.name}-${primary.id}`; + config.models = config.models || {}; + config.models.default = `${primary.name}:${primary.model_name}`; + config.models.authProfile = profileKey; + } + + this.writeConfig(config); + logger.info(`OpenClaw 프로바이더 동기화: ${providers.length}개`); + } catch (e) { + logger.error("OpenClaw 프로바이더 동기화 실패:", e); + } + } + + /** + * 에이전트를 OpenClaw agents에 동기화 + */ + static async syncAgents(): Promise { + try { + const agents = await query( + "SELECT * FROM ai_agents WHERE status = 'active' ORDER BY name" + ); + + const config = this.readConfig(); + const clawAgents: Record = {}; + + for (const agent of agents) { + clawAgents[agent.agent_id] = { + displayName: agent.name, + description: agent.description || "", + model: agent.model, + systemPrompt: agent.system_prompt || "", + tools: agent.tools || [], + ...(agent.config || {}), + }; + } + + config.agents = clawAgents; + this.writeConfig(config); + logger.info(`OpenClaw 에이전트 동기화: ${agents.length}개`); + } catch (e) { + logger.error("OpenClaw 에이전트 동기화 실패:", e); + } + } + + /** + * 전체 동기화 (서버 시작 시) + */ + static async syncAll(): Promise { + await this.syncProviders(); + await this.syncAgents(); + } +} diff --git a/backend-node/src/services/pipelineDeviceConnectionService.ts b/backend-node/src/services/pipelineDeviceConnectionService.ts new file mode 100644 index 00000000..d31fa8c5 --- /dev/null +++ b/backend-node/src/services/pipelineDeviceConnectionService.ts @@ -0,0 +1,280 @@ +import { query, queryOne } from "../database/db"; +import { + PipelineDeviceConnection, + PipelineDeviceConnectionFilter, + PipelineTagMapping, + DeviceConnectionTestResult, +} from "../types/pipelineDeviceTypes"; +import { logger } from "../utils/logger"; +import net from "net"; + +export class PipelineDeviceConnectionService { + // ===== 연결 CRUD ===== + + static async getConnections( + filter: PipelineDeviceConnectionFilter, + userCompanyCode?: string + ) { + const whereConditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (userCompanyCode && userCompanyCode !== "*") { + whereConditions.push(`d.company_code = $${idx++}`); + params.push(userCompanyCode); + } else if (filter.company_code) { + whereConditions.push(`d.company_code = $${idx++}`); + params.push(filter.company_code); + } + + if (filter.protocol) { + whereConditions.push(`d.protocol = $${idx++}`); + params.push(filter.protocol); + } + if (filter.is_active) { + whereConditions.push(`d.is_active = $${idx++}`); + params.push(filter.is_active); + } + if (filter.status) { + whereConditions.push(`d.status = $${idx++}`); + params.push(filter.status); + } + if (filter.search?.trim()) { + whereConditions.push( + `(d.connection_name ILIKE $${idx} OR d.description ILIKE $${idx})` + ); + params.push(`%${filter.search.trim()}%`); + idx++; + } + + const whereClause = + whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; + + const connections = await query( + `SELECT d.*, + (SELECT COUNT(*) FROM pipeline_tag_mappings WHERE connection_id = d.id AND is_active = 'Y') as tag_count, + COALESCE(c.company_name, d.company_code) as company_name + FROM pipeline_device_connections d + LEFT JOIN company_mng c ON d.company_code = c.company_code + ${whereClause} + ORDER BY d.is_active DESC, d.connection_name ASC`, + params + ); + + return { success: true, data: connections }; + } + + static async getConnectionById(id: number) { + const conn = await queryOne( + `SELECT d.*, + (SELECT COUNT(*) FROM pipeline_tag_mappings WHERE connection_id = d.id AND is_active = 'Y') as tag_count + FROM pipeline_device_connections d + WHERE d.id = $1`, + [id] + ); + if (!conn) return { success: false, message: "연결을 찾을 수 없습니다." }; + return { success: true, data: conn }; + } + + static async createConnection(data: Partial) { + if (!data.connection_name || !data.protocol || !data.host || !data.port) { + return { success: false, message: "필수 필드가 누락되었습니다." }; + } + + const result = await query( + `INSERT INTO pipeline_device_connections + (connection_name, description, protocol, host, port, protocol_config, + polling_interval_ms, timeout_ms, retry_count, status, company_code, is_active, created_by) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13) + RETURNING *`, + [ + data.connection_name, + data.description || null, + data.protocol, + data.host, + data.port, + JSON.stringify(data.protocol_config || {}), + data.polling_interval_ms || 1000, + data.timeout_ms || 5000, + data.retry_count || 3, + data.status || "active", + data.company_code || null, + data.is_active || "Y", + data.created_by || null, + ] + ); + + return { success: true, data: result[0], message: "장비 연결이 생성되었습니다." }; + } + + static async updateConnection(id: number, data: Partial) { + const sets: string[] = []; + const params: any[] = []; + let idx = 1; + + if (data.connection_name !== undefined) { sets.push(`connection_name = $${idx++}`); params.push(data.connection_name); } + if (data.description !== undefined) { sets.push(`description = $${idx++}`); params.push(data.description); } + if (data.protocol !== undefined) { sets.push(`protocol = $${idx++}`); params.push(data.protocol); } + if (data.host !== undefined) { sets.push(`host = $${idx++}`); params.push(data.host); } + if (data.port !== undefined) { sets.push(`port = $${idx++}`); params.push(data.port); } + if (data.protocol_config !== undefined) { sets.push(`protocol_config = $${idx++}::jsonb`); params.push(JSON.stringify(data.protocol_config)); } + if (data.polling_interval_ms !== undefined) { sets.push(`polling_interval_ms = $${idx++}`); params.push(data.polling_interval_ms); } + if (data.timeout_ms !== undefined) { sets.push(`timeout_ms = $${idx++}`); params.push(data.timeout_ms); } + if (data.retry_count !== undefined) { sets.push(`retry_count = $${idx++}`); params.push(data.retry_count); } + if (data.status !== undefined) { sets.push(`status = $${idx++}`); params.push(data.status); } + if (data.is_active !== undefined) { sets.push(`is_active = $${idx++}`); params.push(data.is_active); } + + if (sets.length === 0) return this.getConnectionById(id); + + sets.push(`updated_at = NOW()`); + params.push(id); + + const result = await query( + `UPDATE pipeline_device_connections SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`, + params + ); + + if (!result[0]) return { success: false, message: "연결을 찾을 수 없습니다." }; + return { success: true, data: result[0], message: "장비 연결이 수정되었습니다." }; + } + + static async deleteConnection(id: number) { + await query("DELETE FROM pipeline_device_connections WHERE id = $1", [id]); + return { success: true, message: "장비 연결이 삭제되었습니다." }; + } + + static async testConnection(id: number): Promise { + const connResult = await this.getConnectionById(id); + if (!connResult.success || !connResult.data) { + return { success: false, message: "연결을 찾을 수 없습니다." }; + } + + const conn = connResult.data; + const startTime = Date.now(); + + return new Promise((resolve) => { + const socket = new net.Socket(); + const timeout = conn.timeout_ms || 5000; + + socket.setTimeout(timeout); + + socket.connect(conn.port, conn.host, async () => { + const elapsed = Date.now() - startTime; + socket.destroy(); + + await query( + "UPDATE pipeline_device_connections SET last_test_date = NOW(), last_test_result = 'success', last_test_message = $1 WHERE id = $2", + [`TCP 연결 성공 (${elapsed}ms)`, id] + ); + + resolve({ + success: true, + message: `${conn.protocol} 연결 성공 (${elapsed}ms)`, + details: { response_time: elapsed, protocol: conn.protocol, host: conn.host, port: conn.port }, + }); + }); + + socket.on("error", async (err) => { + socket.destroy(); + const msg = `연결 실패: ${err.message}`; + + await query( + "UPDATE pipeline_device_connections SET last_test_date = NOW(), last_test_result = 'failure', last_test_message = $1, status = 'error' WHERE id = $2", + [msg, id] + ).catch(() => {}); + + resolve({ success: false, message: msg, error: { code: (err as any).code, details: err.message } }); + }); + + socket.on("timeout", async () => { + socket.destroy(); + const msg = `연결 타임아웃 (${timeout}ms)`; + + await query( + "UPDATE pipeline_device_connections SET last_test_date = NOW(), last_test_result = 'failure', last_test_message = $1 WHERE id = $2", + [msg, id] + ).catch(() => {}); + + resolve({ success: false, message: msg, error: { code: "TIMEOUT", details: msg } }); + }); + }); + } + + // ===== 태그 매핑 CRUD ===== + + static async getTagMappings(connectionId: number) { + const tags = await query( + "SELECT * FROM pipeline_tag_mappings WHERE connection_id = $1 ORDER BY tag_name", + [connectionId] + ); + return { success: true, data: tags }; + } + + static async createTagMapping(connectionId: number, data: Partial) { + if (!data.tag_name || !data.address) { + return { success: false, message: "태그명과 주소는 필수입니다." }; + } + + const result = await query( + `INSERT INTO pipeline_tag_mappings + (connection_id, tag_name, tag_display_name, tag_unit, tag_data_type, address, address_type, + scale_factor, offset_value, min_value, max_value, description, is_active) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING *`, + [ + connectionId, + data.tag_name, + data.tag_display_name || null, + data.tag_unit || null, + data.tag_data_type || "FLOAT32", + data.address, + data.address_type || null, + data.scale_factor ?? 1.0, + data.offset_value ?? 0.0, + data.min_value ?? null, + data.max_value ?? null, + data.description || null, + data.is_active || "Y", + ] + ); + + return { success: true, data: result[0], message: "태그 매핑이 추가되었습니다." }; + } + + static async updateTagMapping(tagId: number, data: Partial) { + const sets: string[] = []; + const params: any[] = []; + let idx = 1; + + if (data.tag_name !== undefined) { sets.push(`tag_name = $${idx++}`); params.push(data.tag_name); } + if (data.tag_display_name !== undefined) { sets.push(`tag_display_name = $${idx++}`); params.push(data.tag_display_name); } + if (data.tag_unit !== undefined) { sets.push(`tag_unit = $${idx++}`); params.push(data.tag_unit); } + if (data.tag_data_type !== undefined) { sets.push(`tag_data_type = $${idx++}`); params.push(data.tag_data_type); } + if (data.address !== undefined) { sets.push(`address = $${idx++}`); params.push(data.address); } + if (data.address_type !== undefined) { sets.push(`address_type = $${idx++}`); params.push(data.address_type); } + if (data.scale_factor !== undefined) { sets.push(`scale_factor = $${idx++}`); params.push(data.scale_factor); } + if (data.offset_value !== undefined) { sets.push(`offset_value = $${idx++}`); params.push(data.offset_value); } + if (data.min_value !== undefined) { sets.push(`min_value = $${idx++}`); params.push(data.min_value); } + if (data.max_value !== undefined) { sets.push(`max_value = $${idx++}`); params.push(data.max_value); } + if (data.description !== undefined) { sets.push(`description = $${idx++}`); params.push(data.description); } + if (data.is_active !== undefined) { sets.push(`is_active = $${idx++}`); params.push(data.is_active); } + + if (sets.length === 0) return { success: false, message: "변경할 내용이 없습니다." }; + + sets.push(`updated_at = NOW()`); + params.push(tagId); + + const result = await query( + `UPDATE pipeline_tag_mappings SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`, + params + ); + + if (!result[0]) return { success: false, message: "태그를 찾을 수 없습니다." }; + return { success: true, data: result[0], message: "태그 매핑이 수정되었습니다." }; + } + + static async deleteTagMapping(tagId: number) { + await query("DELETE FROM pipeline_tag_mappings WHERE id = $1", [tagId]); + return { success: true, message: "태그 매핑이 삭제되었습니다." }; + } +} diff --git a/backend-node/src/types/aiAgent.ts b/backend-node/src/types/aiAgent.ts new file mode 100644 index 00000000..9b81c5d7 --- /dev/null +++ b/backend-node/src/types/aiAgent.ts @@ -0,0 +1,153 @@ +// AI 에이전트 관련 타입 정의 + +export interface AiAgent { + id: number; + agent_id: string; + name: string; + description?: string; + model: string; + system_prompt?: string; + tools: any[]; + config: Record; + status: "active" | "inactive" | "archived"; + company_code?: string; + created_by: string; + created_at: string; + updated_at: string; +} + +export interface CreateAgentRequest { + agent_id: string; + name: string; + description?: string; + model?: string; + system_prompt?: string; + tools?: any[]; + config?: Record; + company_code?: string; +} + +export interface UpdateAgentRequest { + name?: string; + description?: string; + model?: string; + system_prompt?: string; + tools?: any[]; + config?: Record; + status?: "active" | "inactive" | "archived"; +} + +export interface AiAgentApiKey { + id: number; + name: string; + key_hash: string; + key_prefix: string; + user_id: string; + company_code?: string; + agent_id?: number; + permissions: string[]; + rate_limit: number; + monthly_token_limit: number; + status: "active" | "revoked"; + last_used_at?: string; + usage_count: number; + total_tokens: number; + expires_at?: string; + created_at: string; +} + +export interface CreateApiKeyRequest { + name: string; + agent_id?: number; + permissions?: string[]; + rate_limit?: number; + monthly_token_limit?: number; + expires_at?: string; +} + +export interface AiConversation { + id: number; + conversation_id: string; + agent_id?: number; + user_id?: string; + api_key_id?: number; + title?: string; + message_count: number; + total_tokens: number; + status: string; + metadata: Record; + created_at: string; + updated_at: string; +} + +export interface AiMessage { + id: number; + conversation_id: number; + role: "system" | "user" | "assistant" | "tool"; + content: string; + tool_calls?: any; + token_count: number; + created_at: string; +} + +export interface AiUsageLog { + id: number; + user_id?: string; + api_key_id?: number; + agent_id?: number; + conversation_id?: number; + provider_name?: string; + model_name?: string; + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + cost_usd: number; + response_time_ms?: number; + success: boolean; + error_message?: string; + request_path?: string; + ip_address?: string; + created_at: string; +} + +export interface AiLlmProvider { + id: number; + name: string; + display_name: string; + api_key_encrypted: string; + model_name: string; + endpoint?: string; + priority: number; + max_tokens: number; + temperature: number; + cost_per_1k_input: number; + cost_per_1k_output: number; + is_active: boolean; + config: Record; + created_at: string; + updated_at: string; +} + +export interface CreateProviderRequest { + name: string; + display_name: string; + api_key: string; + model_name: string; + endpoint?: string; + priority?: number; + max_tokens?: number; + temperature?: number; + cost_per_1k_input?: number; + cost_per_1k_output?: number; +} + +export interface UsageSummary { + today_tokens: number; + today_requests: number; + today_cost: number; + month_tokens: number; + month_requests: number; + month_cost: number; + active_agents: number; + active_keys: number; +} diff --git a/backend-node/src/types/pipelineDeviceTypes.ts b/backend-node/src/types/pipelineDeviceTypes.ts new file mode 100644 index 00000000..20022cf3 --- /dev/null +++ b/backend-node/src/types/pipelineDeviceTypes.ts @@ -0,0 +1,105 @@ +// 파이프라인 장비 연결 관련 타입 정의 + +export interface PipelineDeviceConnection { + id?: number; + connection_name: string; + description?: string | null; + protocol: "PLC_ETHERNET" | "MODBUS_TCP" | "OPCUA" | "MQTT" | "REST_API"; + host: string; + port: number; + protocol_config?: Record; + polling_interval_ms?: number; + timeout_ms?: number; + retry_count?: number; + status?: "active" | "inactive" | "error"; + company_code?: string; + is_active?: string; + last_test_date?: Date; + last_test_result?: string; + last_test_message?: string; + created_by?: string; + created_at?: Date; + updated_at?: Date; + // 조인 필드 + tag_count?: number; + company_name?: string; +} + +export interface PipelineTagMapping { + id?: number; + connection_id: number; + tag_name: string; + tag_display_name?: string | null; + tag_unit?: string | null; + tag_data_type: "INT16" | "INT32" | "FLOAT32" | "FLOAT64" | "BOOLEAN" | "STRING"; + address: string; + address_type?: "WORD" | "DWORD" | "FLOAT" | "BIT" | "STRING" | null; + scale_factor?: number; + offset_value?: number; + min_value?: number | null; + max_value?: number | null; + description?: string | null; + is_active?: string; + created_at?: Date; + updated_at?: Date; +} + +export interface PipelineDeviceConnectionFilter { + protocol?: string; + is_active?: string; + company_code?: string; + search?: string; + status?: string; +} + +export interface DeviceConnectionTestResult { + success: boolean; + message: string; + details?: { + response_time?: number; + protocol?: string; + host?: string; + port?: number; + }; + error?: { + code?: string; + details?: string; + }; +} + +// 프로토콜 옵션 +export const PROTOCOL_OPTIONS = [ + { value: "PLC_ETHERNET", label: "PLC Ethernet (MC Protocol)" }, + { value: "MODBUS_TCP", label: "Modbus TCP" }, + { value: "OPCUA", label: "OPC-UA" }, + { value: "MQTT", label: "MQTT" }, + { value: "REST_API", label: "REST API" }, +]; + +// 프로토콜별 기본 포트 +export const PROTOCOL_DEFAULTS: Record = { + PLC_ETHERNET: { port: 5000 }, + MODBUS_TCP: { port: 502 }, + OPCUA: { port: 4840 }, + MQTT: { port: 1883 }, + REST_API: { port: 443 }, +}; + +// 태그 데이터 타입 옵션 +export const TAG_DATA_TYPE_OPTIONS = [ + { value: "INT16", label: "INT16 (정수 16비트)" }, + { value: "INT32", label: "INT32 (정수 32비트)" }, + { value: "FLOAT32", label: "FLOAT32 (실수 32비트)" }, + { value: "FLOAT64", label: "FLOAT64 (실수 64비트)" }, + { value: "BOOLEAN", label: "BOOLEAN (불리언)" }, + { value: "STRING", label: "STRING (문자열)" }, +]; + +// 주소 타입 옵션 +export const ADDRESS_TYPE_OPTIONS = [ + { value: "WORD", label: "WORD" }, + { value: "DWORD", label: "DWORD" }, + { value: "FLOAT", label: "FLOAT" }, + { value: "BIT", label: "BIT" }, + { value: "STRING", label: "STRING" }, +]; diff --git a/backend-node/src/utils/startOpenClaw.ts b/backend-node/src/utils/startOpenClaw.ts new file mode 100644 index 00000000..79bfa778 --- /dev/null +++ b/backend-node/src/utils/startOpenClaw.ts @@ -0,0 +1,107 @@ +/** + * OpenClaw 멀티 에이전트 Gateway를 자식 프로세스로 기동 + * - backend-node 서버 기동 시 함께 띄우고, 종료 시 함께 종료 + * - OpenClaw이 설치되어 있지 않으면 스킵 (backend는 계속 동작) + */ +import { spawn, ChildProcess } from "child_process"; +import { logger } from "./logger"; + +const OPENCLAW_PORT = process.env.OPENCLAW_GATEWAY_PORT || "18789"; +const OPENCLAW_ENABLED = process.env.OPENCLAW_ENABLED !== "false"; + +let openClawProcess: ChildProcess | null = null; + +/** + * OpenClaw Gateway 기동 + */ +export function startOpenClaw(): void { + if (!OPENCLAW_ENABLED) { + logger.info("⏭️ OpenClaw Gateway 비활성화 (OPENCLAW_ENABLED=false)"); + return; + } + + try { + // openclaw CLI가 설치되어 있는지 확인 + const which = require("child_process").execSync("which openclaw 2>/dev/null || where openclaw 2>nul", { + encoding: "utf8", + timeout: 5000, + }).trim(); + + if (!which) { + logger.info("⏭️ OpenClaw 스킵 (설치되지 않음)"); + return; + } + } catch { + // npm global로 설치 안 됐으면 npx로 시도 + logger.info("⏭️ OpenClaw CLI 미발견 → npx openclaw로 시도"); + } + + openClawProcess = spawn("npx", ["openclaw", "gateway", "--port", OPENCLAW_PORT], { + stdio: "pipe", + env: { + ...process.env, + OPENCLAW_GATEWAY_PORT: OPENCLAW_PORT, + }, + shell: true, + }); + + openClawProcess.stdout?.on("data", (data: Buffer) => { + const msg = data.toString().trim(); + if (msg) logger.info(`[OpenClaw] ${msg}`); + }); + + openClawProcess.stderr?.on("data", (data: Buffer) => { + const msg = data.toString().trim(); + if (msg) logger.warn(`[OpenClaw] ${msg}`); + }); + + openClawProcess.on("error", (err) => { + logger.warn(`⚠️ OpenClaw Gateway 프로세스 에러: ${err.message}`); + openClawProcess = null; + }); + + openClawProcess.on("exit", (code, signal) => { + openClawProcess = null; + if (code != null && code !== 0) { + logger.warn(`⚠️ OpenClaw Gateway 종료 (code=${code}, signal=${signal})`); + } + }); + + logger.info(`🤖 OpenClaw Gateway 기동 (포트 ${OPENCLAW_PORT})`); +} + +/** + * OpenClaw Gateway 프로세스 종료 + */ +export function stopOpenClaw(): void { + if (openClawProcess && openClawProcess.kill) { + openClawProcess.kill("SIGTERM"); + openClawProcess = null; + logger.info("🤖 OpenClaw Gateway 프로세스 종료"); + } +} + +/** + * OpenClaw Gateway 상태 확인 (프로세스 또는 포트 체크) + */ +export async function isOpenClawRunning(): Promise { + if (openClawProcess !== null && !openClawProcess.killed) return true; + // 외부에서 이미 실행 중인 경우도 체크 + try { + const http = await import("http"); + return new Promise((resolve) => { + const req = http.get(`http://127.0.0.1:${OPENCLAW_PORT}/healthz`, (res) => { + resolve(res.statusCode === 200); + }); + req.on("error", () => resolve(false)); + req.setTimeout(2000, () => { req.destroy(); resolve(false); }); + }); + } catch { return false; } +} + +/** + * OpenClaw Gateway URL + */ +export function getOpenClawUrl(): string { + return `http://127.0.0.1:${OPENCLAW_PORT}`; +} diff --git a/deploy/onpremise/docker-compose.yml b/deploy/onpremise/docker-compose.yml index a779cad7..4e9c6f57 100644 --- a/deploy/onpremise/docker-compose.yml +++ b/deploy/onpremise/docker-compose.yml @@ -32,7 +32,7 @@ services: # ============================================ backend: image: harbor.wace.me/speefox_vexplor/vexplor-backend:${IMAGE_TAG:-latest} - container_name: vexplor-backend + container_name: pipeline-backend environment: NODE_ENV: production PORT: 3001 @@ -79,7 +79,7 @@ services: # ============================================ frontend: image: harbor.wace.me/speefox_vexplor/vexplor-frontend:${IMAGE_TAG:-latest} - container_name: vexplor-frontend + container_name: pipeline-front environment: NODE_ENV: production PORT: 3000 diff --git a/docker-compose.backend.win.yml b/docker-compose.backend.win.yml index 72e0d987..26fbaab2 100644 --- a/docker-compose.backend.win.yml +++ b/docker-compose.backend.win.yml @@ -2,11 +2,11 @@ version: "3.8" services: # Node.js 백엔드 - backend: + pipeline-backend: build: context: ./backend-node dockerfile: Dockerfile.win - container_name: pms-backend-win + container_name: pipeline-backend ports: - "8080:8080" environment: @@ -24,7 +24,7 @@ services: - /app/node_modules - /app/dist networks: - - pms-network + - pipeline-network restart: unless-stopped healthcheck: test: @@ -45,6 +45,6 @@ services: start_period: 90s networks: - pms-network: + pipeline-network: driver: bridge external: false diff --git a/docker-compose.frontend.win.yml b/docker-compose.frontend.win.yml index 79589463..abc53f35 100644 --- a/docker-compose.frontend.win.yml +++ b/docker-compose.frontend.win.yml @@ -2,11 +2,11 @@ version: "3.8" services: # Next.js 프론트엔드만 - frontend: + pipeline-front: build: context: ./frontend dockerfile: ../docker/dev/frontend.Dockerfile - container_name: pms-frontend-win + container_name: pipeline-front ports: - "9771:3000" environment: @@ -24,7 +24,7 @@ services: - /app/node_modules - /app/.next networks: - - pms-network + - pipeline-network restart: unless-stopped healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000", "||", "exit", "1"] @@ -34,6 +34,6 @@ services: start_period: 60s networks: - pms-network: + pipeline-network: driver: bridge external: false diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index 115aef91..35986008 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -2,11 +2,11 @@ version: "3.8" services: # Node.js 백엔드 - backend: + pipeline-backend: build: context: ../../backend-node dockerfile: ../docker/deploy/backend.Dockerfile - container_name: pms-backend-prod + container_name: pipeline-backend restart: always environment: NODE_ENV: production @@ -36,19 +36,19 @@ services: - traefik.http.services.backend.loadbalancer.server.port=3001 # Next.js 프론트엔드 - frontend: + pipeline-front: build: context: ../../frontend dockerfile: ../docker/deploy/frontend.Dockerfile args: - NEXT_PUBLIC_API_URL=https://api.vexplor.com/api - - SERVER_API_URL=http://backend:3001 - container_name: pms-frontend-prod + - SERVER_API_URL=http://pipeline-backend:3001 + container_name: pipeline-front restart: always environment: NODE_ENV: production NEXT_PUBLIC_API_URL: https://api.vexplor.com/api - SERVER_API_URL: "http://backend:3001" + SERVER_API_URL: "http://pipeline-backend:3001" NEXT_TELEMETRY_DISABLED: "1" PORT: "3000" HOSTNAME: 0.0.0.0 diff --git a/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml index 5002e813..ff567689 100644 --- a/docker/dev/docker-compose.backend.mac.yml +++ b/docker/dev/docker-compose.backend.mac.yml @@ -1,32 +1,23 @@ services: # Node.js 백엔드 - backend: + pipeline-backend: build: context: ../../backend-node dockerfile: ../docker/dev/backend.Dockerfile - container_name: pms-backend-mac + container_name: pipeline-backend + env_file: + - ../../backend-node/.env ports: - "8080:8080" extra_hosts: - "host.docker.internal:host-gateway" environment: - - NODE_ENV=development - - PORT=8080 - - DATABASE_URL=${DATABASE_URL} - - JWT_SECRET=${JWT_SECRET} - - JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h} - CORS_ORIGIN=http://localhost:9771 - - CORS_CREDENTIALS=true - - LOG_LEVEL=debug - - ENCRYPTION_KEY=${ENCRYPTION_KEY} - - KMA_API_KEY=${KMA_API_KEY} - - ITS_API_KEY=${ITS_API_KEY} - - EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-} volumes: - ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영 - /app/node_modules networks: - - pms-network + - pipeline-network restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] @@ -36,5 +27,5 @@ services: start_period: 60s networks: - pms-network: + pipeline-network: driver: bridge diff --git a/docker/dev/docker-compose.frontend.mac.yml b/docker/dev/docker-compose.frontend.mac.yml index eda932da..d9ca1e29 100644 --- a/docker/dev/docker-compose.frontend.mac.yml +++ b/docker/dev/docker-compose.frontend.mac.yml @@ -1,25 +1,27 @@ services: # Next.js 프론트엔드만 - frontend: + pipeline-front: build: context: ../../frontend dockerfile: ../docker/dev/frontend.Dockerfile - container_name: pms-frontend-mac + container_name: pipeline-front ports: - "9771:3000" environment: - NEXT_PUBLIC_API_URL=http://localhost:8080/api - - SERVER_API_URL=http://pms-backend-mac:8080 + - SERVER_API_URL=http://pipeline-backend:8080 - NODE_OPTIONS=--max-old-space-size=8192 - NEXT_TELEMETRY_DISABLED=1 - volumes: - - ../../frontend:/app - - /app/node_modules - - /app/.next + - WATCHPACK_POLLING=true + - WATCHPACK_POLLING_INTERVAL=3000 + # volumes: + # - ../../frontend:/app # 소스 마운트 (Docker for Mac에서 컴파일 느림 → 비활성화) + # - /app/node_modules + # - /app/.next networks: - - pms-network + - pipeline-network restart: unless-stopped networks: - pms-network: + pipeline-network: driver: bridge diff --git a/docker/prod/docker-compose.backend.prod.yml b/docker/prod/docker-compose.backend.prod.yml index 8ab60253..8bc3b07e 100644 --- a/docker/prod/docker-compose.backend.prod.yml +++ b/docker/prod/docker-compose.backend.prod.yml @@ -1,14 +1,14 @@ services: # Node.js 백엔드 - plm-backend: + pipeline-backend: build: context: ../../backend-node dockerfile: ../docker/prod/backend.Dockerfile # 운영용 Dockerfile - container_name: pms-backend-prod + container_name: pipeline-backend ports: - "8080:8080" # 호스트:컨테이너 포트 매핑 networks: - - pms-network + - pipeline-network environment: - NODE_ENV=production - PORT=8080 @@ -37,5 +37,5 @@ services: start_period: 60s networks: - pms-network: + pipeline-network: external: true # 외부에서 생성된 네트워크 사용 diff --git a/docker/prod/docker-compose.frontend.prod.yml b/docker/prod/docker-compose.frontend.prod.yml index 227cd76e..96f1d84e 100644 --- a/docker/prod/docker-compose.frontend.prod.yml +++ b/docker/prod/docker-compose.frontend.prod.yml @@ -1,12 +1,12 @@ services: # Next.js 프론트엔드 - plm-frontend: + pipeline-front: build: context: ../../frontend dockerfile: ../docker/prod/frontend.Dockerfile args: - NEXT_PUBLIC_API_URL=https://api.vexplor.com - container_name: plm-frontend + container_name: pipeline-front restart: always environment: NODE_ENV: production diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 73a8de80..b857b8f9 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -11,16 +11,14 @@ export default function LoginPage() { isLoading, error, showPassword, - isPopMode, handleInputChange, handleLogin, togglePasswordVisibility, - togglePopMode, } = useLogin(); return ( -
-
+
+
diff --git a/frontend/app/(main)/COMPANY_10/design/change-management/page.tsx b/frontend/app/(main)/COMPANY_10/design/change-management/page.tsx deleted file mode 100644 index 39f06769..00000000 --- a/frontend/app/(main)/COMPANY_10/design/change-management/page.tsx +++ /dev/null @@ -1,1524 +0,0 @@ -"use client"; - -import React, { useState, useMemo, useEffect, useCallback } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Badge } from "@/components/ui/badge"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, - DialogDescription, -} from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { - Plus, - Save, - Inbox, - Pencil, - FileText, - XCircle, - ArrowRight, - Paperclip, - Upload, - Loader2, - Settings2, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { toast } from "sonner"; -import { - getDesignRequestList, - createDesignRequest, - updateDesignRequest, - addRequestHistory, - getEcnList, - createEcn, - updateEcn, -} from "@/lib/api/design"; -import { useTableSettings } from "@/hooks/useTableSettings"; -import { TableSettingsModal } from "@/components/common/TableSettingsModal"; -import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; -import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; - -// --- Types --- -type ChangeType = "설계오류" | "원가절감" | "고객요청" | "공정개선" | "법규대응"; -type EcrStatus = "요청접수" | "영향도분석" | "ECN발행" | "기각"; -type EcnStatus = "ECN발행" | "도면변경" | "통보완료" | "적용완료"; -type TabType = "ecr" | "ecn"; - -interface EcrHistory { - status: string; - date: string; - user: string; - desc: string; -} - -interface EcrItem { - id: string; - _id?: string; - date: string; - changeType: ChangeType; - urgency: "보통" | "긴급"; - status: EcrStatus; - target: string; - drawingNo: string; - reqDept: string; - requester: string; - reason: string; - content: string; - impact: string[]; - applyTiming: string; - ecnNo: string; - history: EcrHistory[]; -} - -interface EcnItem { - id: string; - _id?: string; - ecrNo: string; - ecrId?: string; - date: string; - applyDate: string; - status: EcnStatus; - target: string; - drawingBefore: string; - drawingAfter: string; - designer: string; - before: string; - after: string; - reason: string; - notifyDepts: string[]; - remark: string; - history: EcrHistory[]; -} - -// --- Style Helpers --- -const getChangeTypeStyle = (type: ChangeType) => { - switch (type) { - case "설계오류": - return "bg-destructive/10 text-destructive border-destructive/20"; - case "원가절감": - return "bg-success/10 text-success border-success/20"; - case "고객요청": - return "bg-info/10 text-info border-info/20"; - case "공정개선": - return "bg-warning/10 text-warning border-warning/20"; - case "법규대응": - return "bg-primary/10 text-primary border-primary/20"; - default: - return "bg-muted text-muted-foreground border-border"; - } -}; - -const getEcrStatusStyle = (status: EcrStatus) => { - switch (status) { - case "요청접수": - return "bg-info/10 text-info border-info/20"; - case "영향도분석": - return "bg-warning/10 text-warning border-warning/20"; - case "ECN발행": - return "bg-success/10 text-success border-success/20"; - case "기각": - return "bg-muted text-muted-foreground border-border"; - default: - return "bg-muted text-muted-foreground border-border"; - } -}; - -const getEcnStatusStyle = (status: EcnStatus) => { - switch (status) { - case "ECN발행": - return "bg-info/10 text-info border-info/20"; - case "도면변경": - return "bg-primary/10 text-primary border-primary/20"; - case "통보완료": - return "bg-warning/10 text-warning border-warning/20"; - case "적용완료": - return "bg-success/10 text-success border-success/20"; - default: - return "bg-muted text-muted-foreground border-border"; - } -}; - -const getImpactBadgeStyle = (impact: string) => { - switch (impact) { - case "BOM": - return "bg-info/10 text-info border-info/20"; - case "공정": - return "bg-warning/10 text-warning border-warning/20"; - case "금형": - return "bg-destructive/10 text-destructive border-destructive/20"; - case "검사기준": - return "bg-primary/10 text-primary border-primary/20"; - case "구매": - case "원가": - return "bg-success/10 text-success border-success/20"; - default: - return "bg-muted text-muted-foreground border-border"; - } -}; - -const getTimelineStatusStyle = (status: string) => { - switch (status) { - case "기각": - return "bg-muted text-muted-foreground border-border"; - case "적용완료": - case "ECN발행": - return "bg-success/10 text-success border-success/20"; - case "영향도분석": - return "bg-warning/10 text-warning border-warning/20"; - case "도면변경": - return "bg-primary/10 text-primary border-primary/20"; - case "통보완료": - return "bg-info/10 text-info border-info/20"; - default: - return "bg-info/10 text-info border-info/20"; - } -}; - -// --- Constants --- -const CHANGE_TYPES: ChangeType[] = ["설계오류", "원가절감", "고객요청", "공정개선", "법규대응"]; -const ECR_STATUSES: EcrStatus[] = ["요청접수", "영향도분석", "ECN발행", "기각"]; -const ECN_STATUSES: EcnStatus[] = ["ECN발행", "도면변경", "통보완료", "적용완료"]; -const DEPARTMENTS = ["품질팀", "생산팀", "영업팀", "구매팀", "설계팀"]; -const DESIGNERS = ["이설계", "박도면", "최기구", "김전장"]; -const IMPACT_OPTIONS = [ - { key: "BOM", label: "BOM 변경" }, - { key: "공정", label: "공정 변경" }, - { key: "금형", label: "금형 변경" }, - { key: "검사기준", label: "검사기준 변경" }, - { key: "구매", label: "구매 변경" }, - { key: "원가", label: "원가 영향" }, -]; -const NOTIFY_DEPTS = [ - { key: "생산팀", label: "생산팀" }, - { key: "품질팀", label: "품질팀" }, - { key: "구매팀", label: "구매팀" }, - { key: "영업팀", label: "영업팀" }, - { key: "물류팀", label: "물류팀" }, - { key: "금형팀", label: "금형팀" }, -]; - -// --- API Response Mapping --- -function mapEcrFromApi(raw: any): EcrItem { - const history = (raw.history || []).map((h: any) => ({ - status: h.step || h.status || "", - date: h.history_date || "", - user: h.user_name || "", - desc: h.description || "", - })); - return { - id: raw.request_no || raw.id || "", - _id: raw.id, - date: raw.request_date || "", - changeType: (raw.change_type as ChangeType) || "설계오류", - urgency: (raw.urgency as "보통" | "긴급") || "보통", - status: (raw.status as EcrStatus) || "요청접수", - target: raw.target_name || "", - drawingNo: raw.drawing_no || "", - reqDept: raw.req_dept || "", - requester: raw.requester || "", - reason: raw.reason || "", - content: raw.content || "", - impact: Array.isArray(raw.impact) ? raw.impact : [], - applyTiming: raw.apply_timing || "", - ecnNo: raw.ecn_no || "", - history, - }; -} - -function mapEcnFromApi(raw: any, ecrData: EcrItem[]): EcnItem { - const history = (raw.history || []).map((h: any) => ({ - status: h.status || "", - date: h.history_date || "", - user: h.user_name || "", - desc: h.description || "", - })); - const ecrNo = raw.ecr_id - ? ecrData.find((e) => e._id === raw.ecr_id)?.id ?? raw.ecr_id - : ""; - return { - id: raw.ecn_no || raw.id || "", - _id: raw.id, - ecrNo, - ecrId: raw.ecr_id, - date: raw.ecn_date || "", - applyDate: raw.apply_date || "", - status: (raw.status as EcnStatus) || "ECN발행", - target: raw.target || "", - drawingBefore: raw.drawing_before || "", - drawingAfter: raw.drawing_after || "", - designer: raw.designer || "", - before: raw.before_content || "", - after: raw.after_content || "", - reason: raw.reason || "", - notifyDepts: Array.isArray(raw.notify_depts) ? raw.notify_depts : [], - remark: raw.remark || "", - history, - }; -} - -// --- Timeline Component --- -function Timeline({ history }: { history: EcrHistory[] }) { - return ( -
- {history.map((h, idx) => { - const isLast = idx === history.length - 1; - const isRejected = h.status === "기각"; - const isCompleted = h.status === "적용완료"; - return ( -
-
-
- {!isLast && ( -
- )} -
-
-
- - {h.status} - -
-

{h.desc}

-

- {h.date} · {h.user} -

-
-
- ); - })} -
- ); -} - -// --- Grid Columns --- -const ECR_GRID_COLUMNS = [ - { key: "request_no", label: "ECR번호" }, - { key: "change_type", label: "변경유형" }, - { key: "status", label: "상태" }, - { key: "urgency", label: "긴급" }, - { key: "target_name", label: "대상 품목/설비" }, - { key: "drawing_no", label: "도면번호" }, - { key: "req_dept", label: "요청부서" }, - { key: "requester", label: "요청자" }, - { key: "request_date", label: "요청일자" }, - { key: "ecn_no", label: "관련 ECN" }, -]; - -const ECN_GRID_COLUMNS = [ - { key: "ecn_no", label: "ECN번호" }, - { key: "status", label: "상태" }, - { key: "target", label: "대상 품목/설비" }, - { key: "drawing_after", label: "도면 (변경 후)" }, - { key: "designer", label: "설계담당" }, - { key: "ecn_date", label: "발행일자" }, - { key: "apply_date", label: "적용일자" }, - { key: "notify_depts", label: "통보 부서" }, - { key: "ecr_id", label: "관련 ECR" }, -]; - -// --- Main Component --- -export default function DesignChangeManagementPage() { - const tsEcr = useTableSettings("c16-change-management-ecr", "dsn_design_request", ECR_GRID_COLUMNS); - const tsEcn = useTableSettings("c16-change-management-ecn", "dsn_ecn", ECN_GRID_COLUMNS); - const [currentTab, setCurrentTab] = useState("ecr"); - const [ecrData, setEcrData] = useState([]); - const [ecnData, setEcnData] = useState([]); - const [loading, setLoading] = useState(true); - const [selectedId, setSelectedId] = useState(null); - const [detailOpen, setDetailOpen] = useState(false); - - // 검색 필터 (DynamicSearchFilter) - const [searchFilters, setSearchFilters] = useState([]); - - // ECR 모달 - const [isEcrModalOpen, setIsEcrModalOpen] = useState(false); - const [isEcrEditMode, setIsEcrEditMode] = useState(false); - const [ecrForm, setEcrForm] = useState>({}); - const [ecrImpactChecks, setEcrImpactChecks] = useState>({}); - - // ECN 모달 - const [isEcnModalOpen, setIsEcnModalOpen] = useState(false); - const [ecnForm, setEcnForm] = useState>({}); - const [ecnNotifyChecks, setEcnNotifyChecks] = useState>({}); - - // 기각 모달 - const [isRejectModalOpen, setIsRejectModalOpen] = useState(false); - const [rejectReason, setRejectReason] = useState(""); - const [rejectTargetId, setRejectTargetId] = useState(""); - - - const fetchData = useCallback(async () => { - setLoading(true); - try { - const [ecrRes, ecnRes] = await Promise.all([ - getDesignRequestList({ source_type: "ecr" }), - getEcnList(), - ]); - if (ecrRes.success && ecrRes.data) { - setEcrData((ecrRes.data as any[]).map(mapEcrFromApi)); - } - if (ecnRes.success && ecnRes.data) { - const ecrList = ecrRes.success && ecrRes.data ? (ecrRes.data as any[]).map(mapEcrFromApi) : []; - setEcnData((ecnRes.data as any[]).map((r) => mapEcnFromApi(r, ecrList))); - } - } catch { - toast.error("데이터를 불러오는데 실패했어요."); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - // snake_case → camelCase 매핑 (ECR) - const ecrFieldMap: Record = { - request_no: "id", - request_date: "date", - change_type: "changeType", - target_name: "target", - drawing_no: "drawingNo", - req_dept: "reqDept", - ecn_no: "ecnNo", - apply_timing: "applyTiming", - }; - // snake_case → camelCase 매핑 (ECN) - const ecnFieldMap: Record = { - ecn_no: "id", - ecn_date: "date", - apply_date: "applyDate", - drawing_before: "drawingBefore", - drawing_after: "drawingAfter", - ecr_id: "ecrNo", - notify_depts: "notifyDepts", - }; - const getFieldValue = (obj: any, colName: string, map: Record): string => { - const key = map[colName] || colName; - const val = obj[key]; - if (Array.isArray(val)) return val.join(","); - return val !== undefined && val !== null ? String(val) : ""; - }; - - const applyFilters = (items: any[], map: Record) => { - if (searchFilters.length === 0) return items; - return items.filter((item) => { - for (const f of searchFilters) { - const val = getFieldValue(item, f.columnName, map); - if (f.operator === "contains") { - if (!val.toLowerCase().includes(f.value.toLowerCase())) return false; - } else if (f.operator === "equals") { - if (val !== f.value) return false; - } else if (f.operator === "in") { - const allowed = f.value.split("|"); - if (!allowed.includes(val)) return false; - } else if (f.operator === "between") { - const [from, to] = f.value.split("|"); - if (from && val < from) return false; - if (to && val > to) return false; - } - } - return true; - }); - }; - - // --- Filtered Data --- - const filteredEcr = useMemo(() => { - return applyFilters(ecrData, ecrFieldMap) - .sort((a: EcrItem, b: EcrItem) => b.date.localeCompare(a.date)); - }, [ecrData, searchFilters]); - - const filteredEcn = useMemo(() => { - return applyFilters(ecnData, ecnFieldMap) - .sort((a: EcnItem, b: EcnItem) => b.date.localeCompare(a.date)); - }, [ecnData, searchFilters]); - - // --- Status Counts --- - const ecrStatusCounts = useMemo(() => { - const counts: Record = {}; - ECR_STATUSES.forEach((s) => (counts[s] = ecrData.filter((r) => r.status === s).length)); - return counts; - }, [ecrData]); - - const ecnStatusCounts = useMemo(() => { - const counts: Record = {}; - ECN_STATUSES.forEach((s) => (counts[s] = ecnData.filter((r) => r.status === s).length)); - return counts; - }, [ecnData]); - - // --- Selected Items --- - const selectedEcr = useMemo( - () => (currentTab === "ecr" ? ecrData.find((r) => r.id === selectedId) : null), - [ecrData, selectedId, currentTab] - ); - const selectedEcn = useMemo( - () => (currentTab === "ecn" ? ecnData.find((r) => r.id === selectedId) : null), - [ecnData, selectedId, currentTab] - ); - - // --- Tab Switch --- - const handleTabSwitch = (tab: TabType) => { - setCurrentTab(tab); - setSelectedId(null); - }; - - const handleFilterByStatus = (_status: string) => { - // Status filter now handled by DynamicSearchFilter - }; - - // --- ECR/ECN Navigation --- - const navigateToLink = (targetId: string) => { - setDetailOpen(false); - if (targetId.startsWith("ECN")) { - setCurrentTab("ecn"); - setSelectedId(targetId); - } else if (targetId.startsWith("ECR")) { - setCurrentTab("ecr"); - setSelectedId(targetId); - } - }; - - // --- ECR Number Generator --- - const generateEcrNo = useCallback(() => { - const year = new Date().getFullYear(); - const prefix = `ECR-${year}-`; - const existing = ecrData.filter((r) => r.id.startsWith(prefix)); - const maxNum = existing.reduce((max, r) => { - const num = parseInt(r.id.split("-")[2]); - return num > max ? num : max; - }, 0); - return `${prefix}${String(maxNum + 1).padStart(4, "0")}`; - }, [ecrData]); - - const generateEcnNo = useCallback(() => { - const year = new Date().getFullYear(); - const prefix = `ECN-${year}-`; - const existing = ecnData.filter((r) => r.id.startsWith(prefix)); - const maxNum = existing.reduce((max, r) => { - const num = parseInt(r.id.split("-")[2]); - return num > max ? num : max; - }, 0); - return `${prefix}${String(maxNum + 1).padStart(4, "0")}`; - }, [ecnData]); - - // --- ECR Modal --- - const openEcrRegisterModal = () => { - setIsEcrEditMode(false); - setEcrForm({ - id: generateEcrNo(), - date: new Date().toISOString().split("T")[0], - changeType: undefined, - urgency: "보통", - target: "", - drawingNo: "", - reqDept: "", - requester: "", - reason: "", - content: "", - applyTiming: "즉시", - }); - setEcrImpactChecks({}); - setIsEcrModalOpen(true); - }; - - const openEcrEditModal = (id: string) => { - const item = ecrData.find((r) => r.id === id); - if (!item) return; - setIsEcrEditMode(true); - setEcrForm({ ...item }); - const checks: Record = {}; - IMPACT_OPTIONS.forEach((opt) => { - checks[opt.key] = item.impact.includes(opt.key); - }); - setEcrImpactChecks(checks); - setIsEcrModalOpen(true); - }; - - const handleSaveEcr = async () => { - if (!ecrForm.changeType) { - toast.error("변경 유형을 선택해 주세요."); - return; - } - if (!ecrForm.target?.trim()) { - toast.error("대상 품목/설비를 입력해 주세요."); - return; - } - if (!ecrForm.reason?.trim()) { - toast.error("변경 사유를 입력해 주세요."); - return; - } - if (!ecrForm.content?.trim()) { - toast.error("변경 요구 내용을 입력해 주세요."); - return; - } - - const impact = IMPACT_OPTIONS.filter((opt) => ecrImpactChecks[opt.key]).map((opt) => opt.key); - const reqDate = ecrForm.date || new Date().toISOString().split("T")[0]; - const historyEntry = { - step: "요청접수", - history_date: reqDate, - user_name: ecrForm.requester || "시스템", - description: `${ecrForm.reqDept || ""}에서 ECR 등록`, - }; - - if (isEcrEditMode && ecrForm._id) { - const res = await updateDesignRequest(ecrForm._id, { - request_no: ecrForm.id, - request_date: reqDate, - change_type: ecrForm.changeType, - urgency: ecrForm.urgency || "보통", - target_name: ecrForm.target, - drawing_no: ecrForm.drawingNo || "", - req_dept: ecrForm.reqDept || "", - requester: ecrForm.requester || "", - reason: ecrForm.reason, - content: ecrForm.content, - impact, - apply_timing: ecrForm.applyTiming || "즉시", - }); - if (res.success) { - toast.success("ECR이 수정되었어요."); - setIsEcrModalOpen(false); - fetchData(); - } else { - toast.error(res.message || "ECR 수정에 실패했어요."); - } - } else { - const res = await createDesignRequest({ - request_no: ecrForm.id || generateEcrNo(), - source_type: "ecr", - request_date: reqDate, - change_type: ecrForm.changeType, - urgency: ecrForm.urgency || "보통", - status: "요청접수", - target_name: ecrForm.target, - drawing_no: ecrForm.drawingNo || "", - req_dept: ecrForm.reqDept || "", - requester: ecrForm.requester || "", - reason: ecrForm.reason, - content: ecrForm.content, - impact, - apply_timing: ecrForm.applyTiming || "즉시", - history: [historyEntry], - }); - if (res.success) { - toast.success("ECR이 등록되었어요."); - setIsEcrModalOpen(false); - fetchData(); - } else { - toast.error(res.message || "ECR 등록에 실패했어요."); - } - } - }; - - // --- ECN Modal --- - const openEcnIssueModal = (ecrId: string) => { - const ecr = ecrData.find((r) => r.id === ecrId); - if (!ecr) return; - - setEcnForm({ - id: generateEcnNo(), - ecrNo: ecrId, - ecrId: ecr._id, - date: new Date().toISOString().split("T")[0], - target: ecr.target, - reason: ecr.reason, - drawingBefore: ecr.drawingNo, - drawingAfter: "", - designer: "", - before: "", - after: "", - applyDate: "", - remark: "", - }); - setEcnNotifyChecks({}); - setIsEcnModalOpen(true); - }; - - const handleSaveEcn = async () => { - if (!ecnForm.after?.trim()) { - toast.error("변경 후(TO-BE) 내용을 입력해 주세요."); - return; - } - if (!ecnForm.applyDate) { - toast.error("적용일자를 입력해 주세요."); - return; - } - if (!ecnForm.ecrId) { - toast.error("관련 ECR 정보가 없어요."); - return; - } - - const notifyDepts = NOTIFY_DEPTS.filter((d) => ecnNotifyChecks[d.key]).map((d) => d.key); - const ecnDate = ecnForm.date || new Date().toISOString().split("T")[0]; - const historyEntry = { - status: "ECN발행", - history_date: ecnDate, - user_name: ecnForm.designer || "시스템", - description: "ECN 발행", - }; - - const ecnNo = ecnForm.id || generateEcnNo(); - const res = await createEcn({ - ecn_no: ecnNo, - ecr_id: ecnForm.ecrId, - ecn_date: ecnDate, - apply_date: ecnForm.applyDate, - status: "ECN발행", - target: ecnForm.target || "", - drawing_before: ecnForm.drawingBefore || "", - drawing_after: ecnForm.drawingAfter || "(미정)", - designer: ecnForm.designer || "", - before_content: ecnForm.before || "", - after_content: ecnForm.after || "", - reason: ecnForm.reason || "", - remark: ecnForm.remark || "", - notify_depts: notifyDepts, - history: [historyEntry], - }); - - if (res.success) { - await updateDesignRequest(ecnForm.ecrId!, { - status: "ECN발행", - ecn_no: ecnNo, - }); - await addRequestHistory(ecnForm.ecrId!, { - step: "ECN발행", - history_date: ecnDate, - user_name: ecnForm.designer || "시스템", - description: `${ecnNo} 발행`, - }); - toast.success("ECN이 발행되었어요."); - setIsEcnModalOpen(false); - fetchData(); - } else { - toast.error(res.message || "ECN 발행에 실패했어요."); - } - }; - - // --- ECR Reject --- - const openRejectModal = (id: string) => { - setRejectTargetId(id); - setRejectReason(""); - setIsRejectModalOpen(true); - }; - - const handleRejectSubmit = async () => { - if (!rejectReason.trim()) { - toast.error("기각 사유를 입력해 주세요."); - return; - } - - const ecr = ecrData.find((r) => r.id === rejectTargetId); - if (!ecr?._id) { - toast.error("ECR 정보를 찾을 수 없어요."); - return; - } - - const updateRes = await updateDesignRequest(ecr._id, { status: "기각", review_memo: rejectReason }); - if (!updateRes.success) { - toast.error(updateRes.message || "ECR 기각에 실패했어요."); - return; - } - await addRequestHistory(ecr._id, { - step: "기각", - history_date: new Date().toISOString().split("T")[0], - user_name: "설계팀", - description: rejectReason, - }); - toast.success("ECR이 기각되었어요."); - setIsRejectModalOpen(false); - fetchData(); - }; - - // --- Stat Cards --- - const ecrStatCards = [ - { label: "요청접수", value: ecrStatusCounts["요청접수"] || 0, color: "text-info" }, - { label: "영향도분석", value: ecrStatusCounts["영향도분석"] || 0, color: "text-warning" }, - { label: "ECN발행", value: ecrStatusCounts["ECN발행"] || 0, color: "text-success" }, - ]; - - const ecnStatCards = [ - { label: "도면변경", value: ecnStatusCounts["도면변경"] || 0, color: "text-primary" }, - { label: "통보완료", value: ecnStatusCounts["통보완료"] || 0, color: "text-info" }, - { label: "적용완료", value: ecnStatusCounts["적용완료"] || 0, color: "text-success" }, - ]; - - const currentStatCards = currentTab === "ecr" ? ecrStatCards : ecnStatCards; - const currentList = currentTab === "ecr" ? filteredEcr : filteredEcn; - - const handleRowClick = (id: string) => { - setSelectedId(id); - setDetailOpen(true); - }; - - return ( -
- {loading && ( -
- -
- )} - - {/* 탭 선택 + 검색 필터 */} -
-
- -
- {currentTab === "ecr" ? ( - - ) : ( - - )} -
- - {/* 현황 카드 */} -
- {currentStatCards.map((card) => ( - - ))} -
- - {/* 액션 바 */} -
-
-

- {currentTab === "ecr" ? "설계변경요청(ECR) 목록" : "설계변경통지(ECN) 목록"} -

- {currentList.length}건 -
-
- {currentTab === "ecr" && ( - - )} - -
-
- - {/* 테이블 영역 */} -
-
- {currentTab === "ecr" ? ( - {val} }, - { key: "changeType", label: "변경유형", width: "w-[90px]", align: "center" as const, render: (val: any) => {val} }, - { key: "status", label: "상태", width: "w-[90px]", align: "center" as const, render: (val: any) => {val} }, - { key: "urgency", label: "긴급", width: "w-[60px]", align: "center" as const, render: (val: any) => val === "긴급" ? 긴급 : - }, - { key: "target", label: "대상 품목/설비", width: "w-[200px]" }, - { key: "drawingNo", label: "도면번호", width: "w-[150px]" }, - { key: "reqDept", label: "요청부서", width: "w-[80px]" }, - { key: "requester", label: "요청자", width: "w-[70px]" }, - { key: "date", label: "요청일자", width: "w-[100px]" }, - { key: "ecnNo", label: "관련 ECN", width: "w-[130px]", render: (val: any) => val ? : - }, - ] as EDataTableColumn[]} - data={tsEcr.groupData(filteredEcr)} - rowKey={(row) => row.id} - selectedId={selectedId} - onSelect={(id) => { if (id) handleRowClick(id); }} - onRowClick={(row) => handleRowClick(row.id)} - emptyMessage="조건에 맞는 ECR이 없어요" - showRowNumber - showPagination={false} - draggableColumns={false} - /> - ) : ( - {val} }, - { key: "status", label: "상태", width: "w-[90px]", align: "center" as const, render: (val: any) => {val} }, - { key: "target", label: "대상 품목/설비", width: "w-[200px]" }, - { key: "drawingAfter", label: "도면 (변경 후)", width: "w-[160px]", render: (val: any) => {val} }, - { key: "designer", label: "설계담당", width: "w-[80px]" }, - { key: "date", label: "발행일자", width: "w-[100px]" }, - { key: "applyDate", label: "적용일자", width: "w-[100px]" }, - { key: "notifyDepts", label: "통보 부서", width: "w-[140px]", render: (val: any) => {Array.isArray(val) ? val.join(", ") : val} }, - { key: "ecrNo", label: "관련 ECR", width: "w-[130px]", render: (val: any) => }, - ] as EDataTableColumn[]} - data={tsEcn.groupData(filteredEcn)} - rowKey={(row) => row.id} - selectedId={selectedId} - onSelect={(id) => { if (id) handleRowClick(id); }} - onRowClick={(row) => handleRowClick(row.id)} - emptyMessage="조건에 맞는 ECN이 없어요" - showRowNumber - showPagination={false} - draggableColumns={false} - /> - )} -
-
- - {/* 상세 정보 다이얼로그 */} - - - - {currentTab === "ecr" ? `ECR 상세 — ${selectedEcr?.id || ""}` : `ECN 상세 — ${selectedEcn?.id || ""}`} - {currentTab === "ecr" ? "설계변경요청의 상세 정보를 확인해요." : "설계변경통지의 상세 정보를 확인해요."} - -
-
- {/* ECR 상세 */} - {selectedEcr ? ( - <> -
-

- 기본 정보 -

-
-
- ECR번호 - {selectedEcr.id} -
-
- 상태 - - {selectedEcr.status} - -
-
- 변경 유형 - - {selectedEcr.changeType} - -
-
- 긴급도 - - {selectedEcr.urgency === "긴급" ? ( - 긴급 - ) : ( - "보통" - )} - -
-
- 대상 품목/설비 - {selectedEcr.target} -
-
- 도면번호 - {selectedEcr.drawingNo} -
-
- 요청부서 / 요청자 - {selectedEcr.reqDept} / {selectedEcr.requester} -
-
- 요청일자 - {selectedEcr.date} -
-
- 희망 적용시점 - {selectedEcr.applyTiming} -
-
- 관련 ECN - {selectedEcr.ecnNo ? ( - - ) : ( - 미발행 - )} -
-
-
- -
-

변경 사유

-
- {selectedEcr.reason} -
-
- -
-

변경 요구 내용

-
- {selectedEcr.content} -
-
- -
-

영향 범위

-
- {selectedEcr.impact.map((imp) => ( - - {imp} - - ))} -
-
- -
-

처리 이력

- -
- - ) : selectedEcn ? ( - <> -
-

- ECN 기본 정보 -

-
-
- ECN번호 - {selectedEcn.id} -
-
- 상태 - - {selectedEcn.status} - -
-
- 대상 품목/설비 - {selectedEcn.target} -
-
- 설계담당 - {selectedEcn.designer} -
-
- 발행일자 - {selectedEcn.date} -
-
- 적용일자 - {selectedEcn.applyDate} -
-
- 관련 ECR - -
-
- 통보 부서 - {selectedEcn.notifyDepts.join(", ")} -
-
-
- -
-

변경 전/후 비교

-
-
-
- 변경 전 ({selectedEcn.drawingBefore}) -
-
{selectedEcn.before}
-
-
-
- 변경 후 ({selectedEcn.drawingAfter}) -
-
{selectedEcn.after}
-
-
-
- -
-

변경 사유

-
- {selectedEcn.reason} -
- {selectedEcn.remark && ( -

비고: {selectedEcn.remark}

- )} -
- -
-

처리 이력

- -
- - ) : null} -
-
- - {selectedEcr && ( - <> - - {selectedEcr.status === "영향도분석" && ( - <> - - - - )} - - )} - -
-
- - {/* ECR 등록/수정 모달 */} - - - - {isEcrEditMode ? "설계변경요청(ECR) 수정" : "설계변경요청(ECR) 등록"} - {isEcrEditMode ? "ECR 정보를 수정해요." : "새로운 설계변경요청을 등록해요."} - -
-
- {/* 좌측: 요청 정보 */} -
-

변경 요청 정보

- -
- - -
- -
-
- - setEcrForm((p) => ({ ...p, date: e.target.value }))} - className="h-8 text-xs sm:h-10 sm:text-sm" - /> -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
- - setEcrForm((p) => ({ ...p, target: e.target.value }))} - placeholder="품목코드 / 설비명" - className="h-8 text-xs sm:h-10 sm:text-sm" - /> -
- -
- - setEcrForm((p) => ({ ...p, drawingNo: e.target.value }))} - placeholder="DWG-XXX-XXX" - className="h-8 text-xs sm:h-10 sm:text-sm" - /> -
- -
-
- - -
-
- - setEcrForm((p) => ({ ...p, requester: e.target.value }))} - placeholder="요청자명" - className="h-8 text-xs sm:h-10 sm:text-sm" - /> -
-
-
- - {/* 우측: 변경 내용 */} -
-
-

변경 내용

- -
- -