From 229b09b89515c587ddac65e5ebffee85fe40f4b8 Mon Sep 17 00:00:00 2001 From: Johngreen Date: Mon, 27 Apr 2026 22:49:43 +0900 Subject: [PATCH 01/33] =?UTF-8?q?AI=EA=B4=80=EB=A6=AC=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B5=90=EC=B2=B4:=20ai-assistant=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20+=20=EB=A9=80=ED=8B=B0=20=EC=97=90=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=ED=8A=B8=20=EC=98=A4=EC=BC=80=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=9D=B4=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Epic A: ai-assistant 디렉토리/Spring 프록시/프론트엔드 메뉴 완전 제거 Epic B: Flyway 도입 + 13 신규 테이블 마이그레이션 Epic C: 9 서비스 + 7 컨트롤러 + LlmClient 추상화 (Java 21/Spring/MyBatis) Epic D: ApiKey 인증 필터 (sk-pipe-* 키 SHA-256 검증) Epic E: OpenClaw 외부 엔진 docker-compose 통합 Epic F: Next.js 7 페이지 + lib/api/aiAgent.ts 이식 Epic G: 화면 그룹/메뉴 등록 마이그레이션 (V014) Epic H: 통합 빌드 검증 - DB: invyone PostgreSQL에 ai_agents/ai_agent_groups/... 13 테이블 + Quartz - 멀티테넌시: 모든 테이블에 company_code 강제 필터 - LLM: Anthropic/OpenAI/Google/Ollama 직접 클라이언트 (Spring AI 미도입) - 스케줄러: Quartz JDBC JobStore (cron 기반) Co-Authored-By: Claude Opus 4.7 (1M context) --- 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-spring/build.gradle | 4 + .../src/main/java/com/erp/ErpApplication.java | 2 + .../com/erp/ai/client/AnthropicLlmClient.java | 133 + .../com/erp/ai/client/GeminiLlmClient.java | 140 + .../java/com/erp/ai/client/LlmClient.java | 36 + .../com/erp/ai/client/LlmClientFactory.java | 32 + .../com/erp/ai/client/OllamaLlmClient.java | 117 + .../com/erp/ai/client/OpenAiLlmClient.java | 67 + .../com/erp/ai/client/OpenClawClient.java | 160 + .../java/com/erp/ai/config/AiAgentConfig.java | 46 + .../com/erp/ai/config/OpenClawProperties.java | 17 + .../controller/AiAgentApiKeyController.java | 64 + .../erp/ai/controller/AiAgentController.java | 70 + .../AiAgentConversationController.java | 46 + .../ai/controller/AiAgentGroupController.java | 92 + .../controller/AiAgentProviderController.java | 50 + .../ai/controller/AiAgentUsageController.java | 43 + .../erp/ai/controller/AiProxyController.java | 246 ++ .../com/erp/ai/dto/AgentExecuteRequest.java | 8 + .../java/com/erp/ai/dto/AgentRequest.java | 22 + .../com/erp/ai/dto/ApiKeyCreateRequest.java | 16 + .../com/erp/ai/dto/ChatCompletionRequest.java | 18 + .../erp/ai/dto/ChatCompletionResponse.java | 25 + .../com/erp/ai/dto/GroupExecutionResult.java | 27 + .../com/erp/ai/dto/GroupMemberRequest.java | 15 + .../java/com/erp/ai/dto/GroupRequest.java | 13 + .../com/erp/ai/dto/KnowledgeFileRequest.java | 14 + .../java/com/erp/ai/dto/ProviderRequest.java | 21 + .../java/com/erp/ai/dto/ScheduleRequest.java | 16 + .../com/erp/ai/dto/UsageSummaryResponse.java | 23 + .../erp/ai/exception/AiAgentException.java | 11 + .../erp/ai/exception/LlmClientException.java | 24 + .../erp/ai/exception/OpenClawException.java | 29 + .../ai/health/OpenClawHealthIndicator.java | 50 + .../erp/ai/mapper/AiAgentApiKeyMapper.java | 28 + .../ai/mapper/AiAgentConversationMapper.java | 28 + .../com/erp/ai/mapper/AiAgentGroupMapper.java | 24 + .../ai/mapper/AiAgentGroupMemberMapper.java | 22 + .../java/com/erp/ai/mapper/AiAgentMapper.java | 27 + .../erp/ai/mapper/AiAgentMessageMapper.java | 16 + .../erp/ai/mapper/AiAgentScheduleMapper.java | 24 + .../erp/ai/mapper/AiAgentUsageLogMapper.java | 25 + .../erp/ai/mapper/AiAnalysisLogMapper.java | 19 + .../erp/ai/mapper/AiKnowledgeFileMapper.java | 21 + .../erp/ai/mapper/AiLlmProviderMapper.java | 24 + .../main/java/com/erp/ai/model/AiAgent.java | 28 + .../java/com/erp/ai/model/AiAgentApiKey.java | 33 + .../com/erp/ai/model/AiAgentConversation.java | 28 + .../java/com/erp/ai/model/AiAgentGroup.java | 25 + .../com/erp/ai/model/AiAgentGroupMember.java | 28 + .../java/com/erp/ai/model/AiAgentMessage.java | 21 + .../com/erp/ai/model/AiAgentSchedule.java | 30 + .../com/erp/ai/model/AiAgentUsageLog.java | 31 + .../java/com/erp/ai/model/AiAnalysisLog.java | 29 + .../com/erp/ai/model/AiKnowledgeFile.java | 24 + .../java/com/erp/ai/model/AiLlmProvider.java | 30 + .../ai/scheduler/MultiAgentExecutionJob.java | 87 + .../erp/ai/security/AiApiKeyAuthFilter.java | 146 + .../erp/ai/service/AiAgentApiKeyService.java | 132 + .../service/AiAgentConversationService.java | 99 + .../erp/ai/service/AiAgentGroupService.java | 161 + .../ai/service/AiAgentProviderService.java | 126 + .../com/erp/ai/service/AiAgentService.java | 112 + .../erp/ai/service/AiAgentUsageService.java | 83 + .../erp/ai/service/AiAnalysisLogService.java | 36 + .../erp/ai/service/AiKnowledgeService.java | 72 + .../erp/ai/service/AiSchedulerService.java | 184 + .../ai/service/MultiAgentExecutionEngine.java | 439 +++ .../erp/ai/service/OpenClawSyncService.java | 129 + .../java/com/erp/ai/util/AesGcmCipher.java | 93 + .../AiAssistantProxyController.java | 44 - .../java/com/erp/security/SecurityConfig.java | 5 + .../erp/service/AiAssistantProxyService.java | 66 - .../src/main/resources/application.yml | 22 +- .../mapper/ai/AiAgentApiKeyMapper.xml | 83 + .../mapper/ai/AiAgentConversationMapper.xml | 76 + .../mapper/ai/AiAgentGroupMapper.xml | 72 + .../mapper/ai/AiAgentGroupMemberMapper.xml | 67 + .../resources/mapper/ai/AiAgentMapper.xml | 93 + .../mapper/ai/AiAgentMessageMapper.xml | 41 + .../mapper/ai/AiAgentScheduleMapper.xml | 87 + .../mapper/ai/AiAgentUsageLogMapper.xml | 96 + .../mapper/ai/AiAnalysisLogMapper.xml | 64 + .../mapper/ai/AiKnowledgeFileMapper.xml | 68 + .../mapper/ai/AiLlmProviderMapper.xml | 87 + .../resources/mapper/aiAssistantProxy.xml | 6 - docker-compose.backend.win.yml | 34 + .../(main)/admin/aiAssistant/agents/page.tsx | 416 ++ .../aiAssistant/api-keys-manage/page.tsx | 474 +++ .../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 | 103 + .../admin/aiAssistant/dashboard/page.tsx | 190 - .../(main)/admin/aiAssistant/history/page.tsx | 157 - .../admin/aiAssistant/knowledge/page.tsx | 273 ++ .../app/(main)/admin/aiAssistant/layout.tsx | 123 +- .../app/(main)/admin/aiAssistant/page.tsx | 4 +- .../admin/aiAssistant/providers/page.tsx | 384 ++ .../(main)/admin/aiAssistant/usage/page.tsx | 195 - .../admin/aiAssistant/workspace/page.tsx | 710 ++++ .../components/layout/AdminPageRenderer.tsx | 17 +- .../docs/AI_어시스턴트_메뉴_등록_가이드.md | 50 - 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 - 141 files changed, 7508 insertions(+), 9352 deletions(-) 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-spring/src/main/java/com/erp/ai/client/AnthropicLlmClient.java create mode 100644 backend-spring/src/main/java/com/erp/ai/client/GeminiLlmClient.java create mode 100644 backend-spring/src/main/java/com/erp/ai/client/LlmClient.java create mode 100644 backend-spring/src/main/java/com/erp/ai/client/LlmClientFactory.java create mode 100644 backend-spring/src/main/java/com/erp/ai/client/OllamaLlmClient.java create mode 100644 backend-spring/src/main/java/com/erp/ai/client/OpenAiLlmClient.java create mode 100644 backend-spring/src/main/java/com/erp/ai/client/OpenClawClient.java create mode 100644 backend-spring/src/main/java/com/erp/ai/config/AiAgentConfig.java create mode 100644 backend-spring/src/main/java/com/erp/ai/config/OpenClawProperties.java create mode 100644 backend-spring/src/main/java/com/erp/ai/controller/AiAgentApiKeyController.java create mode 100644 backend-spring/src/main/java/com/erp/ai/controller/AiAgentController.java create mode 100644 backend-spring/src/main/java/com/erp/ai/controller/AiAgentConversationController.java create mode 100644 backend-spring/src/main/java/com/erp/ai/controller/AiAgentGroupController.java create mode 100644 backend-spring/src/main/java/com/erp/ai/controller/AiAgentProviderController.java create mode 100644 backend-spring/src/main/java/com/erp/ai/controller/AiAgentUsageController.java create mode 100644 backend-spring/src/main/java/com/erp/ai/controller/AiProxyController.java create mode 100644 backend-spring/src/main/java/com/erp/ai/dto/AgentExecuteRequest.java create mode 100644 backend-spring/src/main/java/com/erp/ai/dto/AgentRequest.java create mode 100644 backend-spring/src/main/java/com/erp/ai/dto/ApiKeyCreateRequest.java create mode 100644 backend-spring/src/main/java/com/erp/ai/dto/ChatCompletionRequest.java create mode 100644 backend-spring/src/main/java/com/erp/ai/dto/ChatCompletionResponse.java create mode 100644 backend-spring/src/main/java/com/erp/ai/dto/GroupExecutionResult.java create mode 100644 backend-spring/src/main/java/com/erp/ai/dto/GroupMemberRequest.java create mode 100644 backend-spring/src/main/java/com/erp/ai/dto/GroupRequest.java create mode 100644 backend-spring/src/main/java/com/erp/ai/dto/KnowledgeFileRequest.java create mode 100644 backend-spring/src/main/java/com/erp/ai/dto/ProviderRequest.java create mode 100644 backend-spring/src/main/java/com/erp/ai/dto/ScheduleRequest.java create mode 100644 backend-spring/src/main/java/com/erp/ai/dto/UsageSummaryResponse.java create mode 100644 backend-spring/src/main/java/com/erp/ai/exception/AiAgentException.java create mode 100644 backend-spring/src/main/java/com/erp/ai/exception/LlmClientException.java create mode 100644 backend-spring/src/main/java/com/erp/ai/exception/OpenClawException.java create mode 100644 backend-spring/src/main/java/com/erp/ai/health/OpenClawHealthIndicator.java create mode 100644 backend-spring/src/main/java/com/erp/ai/mapper/AiAgentApiKeyMapper.java create mode 100644 backend-spring/src/main/java/com/erp/ai/mapper/AiAgentConversationMapper.java create mode 100644 backend-spring/src/main/java/com/erp/ai/mapper/AiAgentGroupMapper.java create mode 100644 backend-spring/src/main/java/com/erp/ai/mapper/AiAgentGroupMemberMapper.java create mode 100644 backend-spring/src/main/java/com/erp/ai/mapper/AiAgentMapper.java create mode 100644 backend-spring/src/main/java/com/erp/ai/mapper/AiAgentMessageMapper.java create mode 100644 backend-spring/src/main/java/com/erp/ai/mapper/AiAgentScheduleMapper.java create mode 100644 backend-spring/src/main/java/com/erp/ai/mapper/AiAgentUsageLogMapper.java create mode 100644 backend-spring/src/main/java/com/erp/ai/mapper/AiAnalysisLogMapper.java create mode 100644 backend-spring/src/main/java/com/erp/ai/mapper/AiKnowledgeFileMapper.java create mode 100644 backend-spring/src/main/java/com/erp/ai/mapper/AiLlmProviderMapper.java create mode 100644 backend-spring/src/main/java/com/erp/ai/model/AiAgent.java create mode 100644 backend-spring/src/main/java/com/erp/ai/model/AiAgentApiKey.java create mode 100644 backend-spring/src/main/java/com/erp/ai/model/AiAgentConversation.java create mode 100644 backend-spring/src/main/java/com/erp/ai/model/AiAgentGroup.java create mode 100644 backend-spring/src/main/java/com/erp/ai/model/AiAgentGroupMember.java create mode 100644 backend-spring/src/main/java/com/erp/ai/model/AiAgentMessage.java create mode 100644 backend-spring/src/main/java/com/erp/ai/model/AiAgentSchedule.java create mode 100644 backend-spring/src/main/java/com/erp/ai/model/AiAgentUsageLog.java create mode 100644 backend-spring/src/main/java/com/erp/ai/model/AiAnalysisLog.java create mode 100644 backend-spring/src/main/java/com/erp/ai/model/AiKnowledgeFile.java create mode 100644 backend-spring/src/main/java/com/erp/ai/model/AiLlmProvider.java create mode 100644 backend-spring/src/main/java/com/erp/ai/scheduler/MultiAgentExecutionJob.java create mode 100644 backend-spring/src/main/java/com/erp/ai/security/AiApiKeyAuthFilter.java create mode 100644 backend-spring/src/main/java/com/erp/ai/service/AiAgentApiKeyService.java create mode 100644 backend-spring/src/main/java/com/erp/ai/service/AiAgentConversationService.java create mode 100644 backend-spring/src/main/java/com/erp/ai/service/AiAgentGroupService.java create mode 100644 backend-spring/src/main/java/com/erp/ai/service/AiAgentProviderService.java create mode 100644 backend-spring/src/main/java/com/erp/ai/service/AiAgentService.java create mode 100644 backend-spring/src/main/java/com/erp/ai/service/AiAgentUsageService.java create mode 100644 backend-spring/src/main/java/com/erp/ai/service/AiAnalysisLogService.java create mode 100644 backend-spring/src/main/java/com/erp/ai/service/AiKnowledgeService.java create mode 100644 backend-spring/src/main/java/com/erp/ai/service/AiSchedulerService.java create mode 100644 backend-spring/src/main/java/com/erp/ai/service/MultiAgentExecutionEngine.java create mode 100644 backend-spring/src/main/java/com/erp/ai/service/OpenClawSyncService.java create mode 100644 backend-spring/src/main/java/com/erp/ai/util/AesGcmCipher.java delete mode 100644 backend-spring/src/main/java/com/erp/controller/AiAssistantProxyController.java delete mode 100644 backend-spring/src/main/java/com/erp/service/AiAssistantProxyService.java create mode 100644 backend-spring/src/main/resources/mapper/ai/AiAgentApiKeyMapper.xml create mode 100644 backend-spring/src/main/resources/mapper/ai/AiAgentConversationMapper.xml create mode 100644 backend-spring/src/main/resources/mapper/ai/AiAgentGroupMapper.xml create mode 100644 backend-spring/src/main/resources/mapper/ai/AiAgentGroupMemberMapper.xml create mode 100644 backend-spring/src/main/resources/mapper/ai/AiAgentMapper.xml create mode 100644 backend-spring/src/main/resources/mapper/ai/AiAgentMessageMapper.xml create mode 100644 backend-spring/src/main/resources/mapper/ai/AiAgentScheduleMapper.xml create mode 100644 backend-spring/src/main/resources/mapper/ai/AiAgentUsageLogMapper.xml create mode 100644 backend-spring/src/main/resources/mapper/ai/AiAnalysisLogMapper.xml create mode 100644 backend-spring/src/main/resources/mapper/ai/AiKnowledgeFileMapper.xml create mode 100644 backend-spring/src/main/resources/mapper/ai/AiLlmProviderMapper.xml delete mode 100644 backend-spring/src/main/resources/mapper/aiAssistantProxy.xml 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/docs/AI_어시스턴트_메뉴_등록_가이드.md 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 diff --git a/ai-assistant/.env.example b/ai-assistant/.env.example deleted file mode 100644 index 18bae847..00000000 --- a/ai-assistant/.env.example +++ /dev/null @@ -1,25 +0,0 @@ -# AI Assistant API (INVION 내장) - 환경 변수 -# 이 파일을 .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 b33996e0..00000000 --- a/ai-assistant/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# AI 어시스턴트 API (INVION 내장) - -INVION와 **같은 서비스**로 동작하도록 이 API는 포트 3100에서 구동되고, backend-node가 `/api/ai/v1` 요청을 여기로 프록시합니다. - -## 동작 방식 - -- **프론트(9771)** → `/api/ai/v1/*` 호출 -- **Next.js** → `8080/api/ai/v1/*` 로 rewrite -- **backend-node(8080)** → `3100/api/v1/*` 로 프록시 → **이 서비스** - -따라서 사용자는 **다른 포트를 쓰지 않고** INVION만 켜도 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 b57aec23..00000000 --- a/ai-assistant/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "ai-assistant-api", - "version": "1.0.0", - "description": "AI Assistant API (INVION 내장) - 포트 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 70b40964..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(); -// INVION 내장 시 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-spring/build.gradle b/backend-spring/build.gradle index 04407810..13290603 100644 --- a/backend-spring/build.gradle +++ b/backend-spring/build.gradle @@ -34,6 +34,10 @@ dependencies { implementation 'org.postgresql:postgresql' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-database-postgresql' + implementation 'org.springframework.boot:spring-boot-starter-quartz' + implementation 'org.springframework.boot:spring-boot-starter-validation' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testCompileOnly 'org.projectlombok:lombok' diff --git a/backend-spring/src/main/java/com/erp/ErpApplication.java b/backend-spring/src/main/java/com/erp/ErpApplication.java index f1dbfd44..81b78386 100644 --- a/backend-spring/src/main/java/com/erp/ErpApplication.java +++ b/backend-spring/src/main/java/com/erp/ErpApplication.java @@ -1,5 +1,6 @@ package com.erp; +import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableAsync; @@ -8,6 +9,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableAsync @EnableScheduling +@MapperScan("com.erp.ai.mapper") public class ErpApplication { public static void main(String[] args) { SpringApplication.run(ErpApplication.class, args); diff --git a/backend-spring/src/main/java/com/erp/ai/client/AnthropicLlmClient.java b/backend-spring/src/main/java/com/erp/ai/client/AnthropicLlmClient.java new file mode 100644 index 00000000..2fd2e653 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/client/AnthropicLlmClient.java @@ -0,0 +1,133 @@ +package com.erp.ai.client; + +import com.erp.ai.exception.LlmClientException; +import com.erp.ai.mapper.AiLlmProviderMapper; +import com.erp.ai.model.AiLlmProvider; +import com.erp.ai.util.AesGcmCipher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Anthropic Claude LLM 클라이언트. + * /v1/messages 엔드포인트. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AnthropicLlmClient implements LlmClient { + + private static final String DEFAULT_BASE_URL = "https://api.anthropic.com"; + private static final String ANTHROPIC_VERSION = "2023-06-01"; + + @Qualifier("llmRestClient") + private final RestClient httpClient; + private final AiLlmProviderMapper providerMapper; + private final AesGcmCipher cipher; + + @Override + public boolean supports(String model) { + return model != null && model.startsWith("claude-"); + } + + @Override + public String providerName() { + return "anthropic"; + } + + @Override + @SuppressWarnings("unchecked") + public Map chat(Map request) { + AiLlmProvider provider = providerMapper.getByName("anthropic"); + if (provider == null || !Boolean.TRUE.equals(provider.getIs_active())) { + throw new LlmClientException("Anthropic 프로바이더가 활성화되지 않았습니다."); + } + String apiKey = cipher.decrypt(provider.getApi_key_encrypted()); + String baseUrl = provider.getEndpoint() != null ? provider.getEndpoint() : DEFAULT_BASE_URL; + + // OpenAI -> Anthropic 메시지 매핑 + List> openaiMsgs = (List>) request.getOrDefault("messages", List.of()); + String systemPrompt = openaiMsgs.stream() + .filter(m -> "system".equals(m.get("role"))) + .map(m -> String.valueOf(m.get("content"))) + .findFirst().orElse(null); + List> anthropicMsgs = openaiMsgs.stream() + .filter(m -> !"system".equals(m.get("role"))) + .map(m -> Map.of("role", String.valueOf(m.get("role")), "content", m.get("content"))) + .toList(); + + Map body = new HashMap<>(); + body.put("model", request.get("model")); + body.put("messages", anthropicMsgs); + if (systemPrompt != null) body.put("system", systemPrompt); + body.put("max_tokens", request.getOrDefault("max_tokens", 2000)); + if (request.get("temperature") != null) body.put("temperature", request.get("temperature")); + + try { + Map resp = httpClient.post() + .uri(baseUrl + "/v1/messages") + .contentType(MediaType.APPLICATION_JSON) + .header("x-api-key", apiKey) + .header("anthropic-version", ANTHROPIC_VERSION) + .body(body) + .retrieve() + .body(Map.class); + return convertToOpenAiFormat(resp, String.valueOf(request.get("model"))); + } catch (RestClientResponseException e) { + log.error("Anthropic API 오류 {}: {}", e.getStatusCode().value(), e.getResponseBodyAsString()); + throw new LlmClientException("Anthropic API 오류: " + e.getMessage(), e.getStatusCode().value()); + } catch (Exception e) { + throw new LlmClientException("Anthropic 호출 실패: " + e.getMessage(), e); + } + } + + @SuppressWarnings("unchecked") + private Map convertToOpenAiFormat(Map anthropic, String model) { + if (anthropic == null) return Map.of(); + + // Anthropic content: [{type:"text", text:"..."}] + String text = ""; + Object content = anthropic.get("content"); + if (content instanceof List list && !list.isEmpty()) { + Object first = list.get(0); + if (first instanceof Map mapItem) { + Object t = mapItem.get("text"); + if (t != null) text = String.valueOf(t); + } + } + + Map usageOut = new HashMap<>(); + Map usage = (Map) anthropic.getOrDefault("usage", Map.of()); + Number prompt = (Number) usage.getOrDefault("input_tokens", 0); + Number completion = (Number) usage.getOrDefault("output_tokens", 0); + usageOut.put("prompt_tokens", prompt.intValue()); + usageOut.put("completion_tokens", completion.intValue()); + usageOut.put("total_tokens", prompt.intValue() + completion.intValue()); + + List> choices = new ArrayList<>(); + choices.add(Map.of( + "index", 0, + "message", Map.of("role", "assistant", "content", text), + "finish_reason", anthropic.getOrDefault("stop_reason", "stop") + )); + + Map out = new HashMap<>(); + out.put("id", anthropic.getOrDefault("id", "chatcmpl-" + UUID.randomUUID())); + out.put("object", "chat.completion"); + out.put("created", System.currentTimeMillis() / 1000); + out.put("model", model); + out.put("choices", choices); + out.put("usage", usageOut); + return out; + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/client/GeminiLlmClient.java b/backend-spring/src/main/java/com/erp/ai/client/GeminiLlmClient.java new file mode 100644 index 00000000..f051ce12 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/client/GeminiLlmClient.java @@ -0,0 +1,140 @@ +package com.erp.ai.client; + +import com.erp.ai.exception.LlmClientException; +import com.erp.ai.mapper.AiLlmProviderMapper; +import com.erp.ai.model.AiLlmProvider; +import com.erp.ai.util.AesGcmCipher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Google Gemini LLM 클라이언트. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GeminiLlmClient implements LlmClient { + + private static final String DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com"; + + @Qualifier("llmRestClient") + private final RestClient httpClient; + private final AiLlmProviderMapper providerMapper; + private final AesGcmCipher cipher; + + @Override + public boolean supports(String model) { + return model != null && model.startsWith("gemini-"); + } + + @Override + public String providerName() { + return "google"; + } + + @Override + @SuppressWarnings("unchecked") + public Map chat(Map request) { + AiLlmProvider provider = providerMapper.getByName("google"); + if (provider == null || !Boolean.TRUE.equals(provider.getIs_active())) { + throw new LlmClientException("Google 프로바이더가 활성화되지 않았습니다."); + } + String apiKey = cipher.decrypt(provider.getApi_key_encrypted()); + String baseUrl = provider.getEndpoint() != null ? provider.getEndpoint() : DEFAULT_BASE_URL; + String model = String.valueOf(request.get("model")); + + // OpenAI messages -> Gemini contents + List> openaiMsgs = (List>) request.getOrDefault("messages", List.of()); + List> geminiContents = new ArrayList<>(); + StringBuilder systemBuf = new StringBuilder(); + for (Map m : openaiMsgs) { + String role = String.valueOf(m.get("role")); + String content = String.valueOf(m.get("content")); + if ("system".equals(role)) { + systemBuf.append(content).append("\n"); + } else { + String mapped = "assistant".equals(role) ? "model" : "user"; + geminiContents.add(Map.of( + "role", mapped, + "parts", List.of(Map.of("text", content)) + )); + } + } + + Map body = new HashMap<>(); + body.put("contents", geminiContents); + if (systemBuf.length() > 0) { + body.put("systemInstruction", Map.of("parts", List.of(Map.of("text", systemBuf.toString())))); + } + Map genConfig = new HashMap<>(); + if (request.get("max_tokens") != null) genConfig.put("maxOutputTokens", request.get("max_tokens")); + if (request.get("temperature") != null) genConfig.put("temperature", request.get("temperature")); + if (!genConfig.isEmpty()) body.put("generationConfig", genConfig); + + try { + Map resp = httpClient.post() + .uri(baseUrl + "/v1beta/models/" + model + ":generateContent?key=" + apiKey) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(Map.class); + return convertToOpenAiFormat(resp, model); + } catch (RestClientResponseException e) { + log.error("Gemini API 오류 {}: {}", e.getStatusCode().value(), e.getResponseBodyAsString()); + throw new LlmClientException("Gemini API 오류: " + e.getMessage(), e.getStatusCode().value()); + } catch (Exception e) { + throw new LlmClientException("Gemini 호출 실패: " + e.getMessage(), e); + } + } + + @SuppressWarnings("unchecked") + private Map convertToOpenAiFormat(Map gemini, String model) { + if (gemini == null) return Map.of(); + String text = ""; + List> candidates = (List>) gemini.getOrDefault("candidates", List.of()); + if (!candidates.isEmpty()) { + Map first = candidates.get(0); + Map content = (Map) first.get("content"); + if (content != null) { + List> parts = (List>) content.getOrDefault("parts", List.of()); + if (!parts.isEmpty()) { + Object t = parts.get(0).get("text"); + if (t != null) text = String.valueOf(t); + } + } + } + + Map usageMeta = (Map) gemini.getOrDefault("usageMetadata", Map.of()); + Number prompt = (Number) usageMeta.getOrDefault("promptTokenCount", 0); + Number completion = (Number) usageMeta.getOrDefault("candidatesTokenCount", 0); + Number total = (Number) usageMeta.getOrDefault("totalTokenCount", prompt.intValue() + completion.intValue()); + + Map usageOut = new HashMap<>(); + usageOut.put("prompt_tokens", prompt.intValue()); + usageOut.put("completion_tokens", completion.intValue()); + usageOut.put("total_tokens", total.intValue()); + + Map out = new HashMap<>(); + out.put("id", "chatcmpl-" + UUID.randomUUID()); + out.put("object", "chat.completion"); + out.put("created", System.currentTimeMillis() / 1000); + out.put("model", model); + out.put("choices", List.of(Map.of( + "index", 0, + "message", Map.of("role", "assistant", "content", text), + "finish_reason", "stop"))); + out.put("usage", usageOut); + return out; + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/client/LlmClient.java b/backend-spring/src/main/java/com/erp/ai/client/LlmClient.java new file mode 100644 index 00000000..dce02cda --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/client/LlmClient.java @@ -0,0 +1,36 @@ +package com.erp.ai.client; + +import java.util.List; +import java.util.Map; + +/** + * LLM 클라이언트 추상화. + * architecture §9.2. + */ +public interface LlmClient { + + /** + * 모델 매칭 (LlmClientFactory 라우팅용). + */ + boolean supports(String model); + + /** + * provider name (anthropic / openai / google / deepseek / ollama). + */ + String providerName(); + + /** + * 동기 채팅 호출. OpenAI 호환 형식 응답. + * + * @param request {model, messages, max_tokens, temperature, ...} + * @return OpenAI 호환 응답 맵 (id, object, choices, usage 등) + */ + Map chat(Map request); + + /** + * 사용 가능한 모델 목록. + */ + default List> listModels() { + return List.of(); + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/client/LlmClientFactory.java b/backend-spring/src/main/java/com/erp/ai/client/LlmClientFactory.java new file mode 100644 index 00000000..445e5ca1 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/client/LlmClientFactory.java @@ -0,0 +1,32 @@ +package com.erp.ai.client; + +import com.erp.ai.exception.LlmClientException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 모델 이름으로 적절한 LlmClient 구현체 라우팅. + * architecture §9.3. + */ +@Component +@RequiredArgsConstructor +public class LlmClientFactory { + + private final List clients; + + public LlmClient pick(String model) { + if (model == null || model.isBlank()) { + throw new LlmClientException("모델이 지정되지 않았습니다."); + } + return clients.stream() + .filter(c -> c.supports(model)) + .findFirst() + .orElseThrow(() -> new LlmClientException("지원하지 않는 모델: " + model)); + } + + public List all() { + return clients; + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/client/OllamaLlmClient.java b/backend-spring/src/main/java/com/erp/ai/client/OllamaLlmClient.java new file mode 100644 index 00000000..c69d1d81 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/client/OllamaLlmClient.java @@ -0,0 +1,117 @@ +package com.erp.ai.client; + +import com.erp.ai.exception.LlmClientException; +import com.erp.ai.mapper.AiLlmProviderMapper; +import com.erp.ai.model.AiLlmProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Ollama 로컬 LLM 클라이언트. + * /api/chat 엔드포인트. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OllamaLlmClient implements LlmClient { + + private static final String DEFAULT_BASE_URL = "http://localhost:11434"; + + @Qualifier("llmRestClient") + private final RestClient httpClient; + private final AiLlmProviderMapper providerMapper; + + @Override + public boolean supports(String model) { + // Ollama는 사용자 정의 모델명. 하위 호환을 위해 다른 클라이언트가 지원 안하는 경우 fallback. + // 명시적 prefix: "ollama:" 또는 "llama-" / "mistral-" 등 + if (model == null) return false; + String lower = model.toLowerCase(); + return lower.startsWith("ollama:") + || lower.startsWith("llama") + || lower.startsWith("mistral") + || lower.startsWith("qwen") + || lower.startsWith("phi"); + } + + @Override + public String providerName() { + return "ollama"; + } + + @Override + @SuppressWarnings("unchecked") + public Map chat(Map request) { + AiLlmProvider provider = providerMapper.getByName("ollama"); + String baseUrl = provider != null && provider.getEndpoint() != null ? provider.getEndpoint() : DEFAULT_BASE_URL; + + String model = String.valueOf(request.get("model")); + if (model.startsWith("ollama:")) model = model.substring(7); + + Map body = new HashMap<>(); + body.put("model", model); + body.put("messages", request.getOrDefault("messages", List.of())); + body.put("stream", false); + Map options = new HashMap<>(); + if (request.get("max_tokens") != null) options.put("num_predict", request.get("max_tokens")); + if (request.get("temperature") != null) options.put("temperature", request.get("temperature")); + if (!options.isEmpty()) body.put("options", options); + + try { + Map resp = httpClient.post() + .uri(baseUrl + "/api/chat") + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(Map.class); + return convertToOpenAiFormat(resp, model); + } catch (RestClientResponseException e) { + log.error("Ollama API 오류 {}: {}", e.getStatusCode().value(), e.getResponseBodyAsString()); + throw new LlmClientException("Ollama API 오류: " + e.getMessage(), e.getStatusCode().value()); + } catch (Exception e) { + throw new LlmClientException("Ollama 호출 실패: " + e.getMessage(), e); + } + } + + @SuppressWarnings("unchecked") + private Map convertToOpenAiFormat(Map ollama, String model) { + if (ollama == null) return Map.of(); + Map message = (Map) ollama.getOrDefault("message", Map.of()); + String content = String.valueOf(message.getOrDefault("content", "")); + + Number promptCount = (Number) ollama.getOrDefault("prompt_eval_count", 0); + Number evalCount = (Number) ollama.getOrDefault("eval_count", 0); + + Map usageOut = new HashMap<>(); + usageOut.put("prompt_tokens", promptCount.intValue()); + usageOut.put("completion_tokens", evalCount.intValue()); + usageOut.put("total_tokens", promptCount.intValue() + evalCount.intValue()); + + List> choices = new ArrayList<>(); + choices.add(Map.of( + "index", 0, + "message", Map.of("role", "assistant", "content", content), + "finish_reason", "stop" + )); + + Map out = new HashMap<>(); + out.put("id", "chatcmpl-" + UUID.randomUUID()); + out.put("object", "chat.completion"); + out.put("created", System.currentTimeMillis() / 1000); + out.put("model", model); + out.put("choices", choices); + out.put("usage", usageOut); + return out; + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/client/OpenAiLlmClient.java b/backend-spring/src/main/java/com/erp/ai/client/OpenAiLlmClient.java new file mode 100644 index 00000000..931957f8 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/client/OpenAiLlmClient.java @@ -0,0 +1,67 @@ +package com.erp.ai.client; + +import com.erp.ai.exception.LlmClientException; +import com.erp.ai.mapper.AiLlmProviderMapper; +import com.erp.ai.model.AiLlmProvider; +import com.erp.ai.util.AesGcmCipher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; + +import java.util.Map; + +/** + * OpenAI LLM 클라이언트 (DeepSeek 등 OpenAI 호환 프로바이더 포함). + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OpenAiLlmClient implements LlmClient { + + private static final String DEFAULT_BASE_URL = "https://api.openai.com"; + + @Qualifier("llmRestClient") + private final RestClient httpClient; + private final AiLlmProviderMapper providerMapper; + private final AesGcmCipher cipher; + + @Override + public boolean supports(String model) { + return model != null && (model.startsWith("gpt-") || model.startsWith("o1-") || model.startsWith("o3-")); + } + + @Override + public String providerName() { + return "openai"; + } + + @Override + @SuppressWarnings("unchecked") + public Map chat(Map request) { + AiLlmProvider provider = providerMapper.getByName("openai"); + if (provider == null || !Boolean.TRUE.equals(provider.getIs_active())) { + throw new LlmClientException("OpenAI 프로바이더가 활성화되지 않았습니다."); + } + String apiKey = cipher.decrypt(provider.getApi_key_encrypted()); + String baseUrl = provider.getEndpoint() != null ? provider.getEndpoint() : DEFAULT_BASE_URL; + + try { + return httpClient.post() + .uri(baseUrl + "/v1/chat/completions") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + apiKey) + .body(request) + .retrieve() + .body(Map.class); + } catch (RestClientResponseException e) { + log.error("OpenAI API 오류 {}: {}", e.getStatusCode().value(), e.getResponseBodyAsString()); + throw new LlmClientException("OpenAI API 오류: " + e.getMessage(), e.getStatusCode().value()); + } catch (Exception e) { + throw new LlmClientException("OpenAI 호출 실패: " + e.getMessage(), e); + } + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/client/OpenClawClient.java b/backend-spring/src/main/java/com/erp/ai/client/OpenClawClient.java new file mode 100644 index 00000000..bc8a5de6 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/client/OpenClawClient.java @@ -0,0 +1,160 @@ +package com.erp.ai.client; + +import com.erp.ai.config.OpenClawProperties; +import com.erp.ai.exception.OpenClawException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; + +import java.util.List; +import java.util.Map; + +/** + * OpenClaw 외부 AI 엔진(port 18789) HTTP 클라이언트. + * Spring RestClient(동기) 사용 — invyone 기존 RestTemplate 패턴과 일관성 유지. + * enabled=false 이면 모든 메서드가 OpenClawException(503) 발생. + */ +@Slf4j +@Component +public class OpenClawClient { + + private final OpenClawProperties props; + private final RestClient restClient; + + public OpenClawClient(OpenClawProperties props) { + this.props = props; + this.restClient = RestClient.builder() + .baseUrl(props.getGatewayUrl()) + .defaultHeader("X-Pipeline-Source", "invyone") + .build(); + } + + /** + * OpenClaw 헬스 상태 확인. + * + * @return true = 정상, false = 응답 없음 또는 비정상 + */ + public boolean isHealthy() { + if (!props.isEnabled()) { + return false; + } + try { + restClient.get() + .uri("/health") + .retrieve() + .toBodilessEntity(); + return true; + } catch (Exception e) { + log.warn("OpenClaw health check failed: {}", e.getMessage()); + return false; + } + } + + /** + * OpenAI 호환 chat/completions 프록시. + * + * @param request OpenAI 형식 요청 맵 (model, messages, ...) + * @return OpenAI 형식 응답 맵 + * @throws OpenClawException 비활성 또는 HTTP 오류 시 + */ + @SuppressWarnings("unchecked") + public Map chatCompletion(Map request) { + assertEnabled(); + try { + return restClient.post() + .uri("/v1/chat/completions") + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(Map.class); + } catch (RestClientResponseException e) { + log.error("OpenClaw chatCompletion HTTP error {}: {}", e.getStatusCode().value(), e.getMessage()); + throw new OpenClawException("OpenClaw 응답 오류: " + e.getMessage(), e.getStatusCode().value()); + } catch (ResourceAccessException e) { + log.warn("OpenClaw chatCompletion connection failed: {}", e.getMessage()); + throw new OpenClawException("OpenClaw 연결 실패: " + e.getMessage(), e); + } + } + + /** + * OpenClaw에서 사용 가능한 모델 목록 조회. + * + * @return 모델 ID 목록 + * @throws OpenClawException 비활성 또는 HTTP 오류 시 + */ + @SuppressWarnings("unchecked") + public List listModels() { + assertEnabled(); + try { + Map response = restClient.get() + .uri("/v1/models") + .retrieve() + .body(Map.class); + if (response == null) { + return List.of(); + } + Object data = response.get("data"); + if (data instanceof List list) { + return list.stream() + .filter(item -> item instanceof Map) + .map(item -> (String) ((Map) item).get("id")) + .filter(id -> id != null) + .toList(); + } + return List.of(); + } catch (RestClientResponseException e) { + log.error("OpenClaw listModels HTTP error {}: {}", e.getStatusCode().value(), e.getMessage()); + throw new OpenClawException("OpenClaw 모델 목록 조회 오류: " + e.getMessage(), e.getStatusCode().value()); + } catch (ResourceAccessException e) { + log.warn("OpenClaw listModels connection failed: {}", e.getMessage()); + throw new OpenClawException("OpenClaw 연결 실패: " + e.getMessage(), e); + } + } + + /** + * OpenClaw auth profiles 동기화 (LLM API 키들). + */ + public void syncAuthProfiles(Map body) { + assertEnabled(); + try { + restClient.post() + .uri("/v1/sync/auth-profiles") + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .toBodilessEntity(); + } catch (RestClientResponseException e) { + throw new OpenClawException("OpenClaw auth-profiles sync HTTP error: " + e.getMessage(), e.getStatusCode().value()); + } catch (ResourceAccessException e) { + throw new OpenClawException("OpenClaw auth-profiles sync 연결 실패: " + e.getMessage(), e); + } + } + + /** + * OpenClaw agents 동기화. + */ + public void syncAgents(Map body) { + assertEnabled(); + try { + restClient.post() + .uri("/v1/sync/agents") + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .toBodilessEntity(); + } catch (RestClientResponseException e) { + throw new OpenClawException("OpenClaw agents sync HTTP error: " + e.getMessage(), e.getStatusCode().value()); + } catch (ResourceAccessException e) { + throw new OpenClawException("OpenClaw agents sync 연결 실패: " + e.getMessage(), e); + } + } + + private void assertEnabled() { + if (!props.isEnabled()) { + throw new OpenClawException("OpenClaw 비활성 상태입니다. OPENCLAW_ENABLED=true 로 설정하세요.", 503); + } + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/config/AiAgentConfig.java b/backend-spring/src/main/java/com/erp/ai/config/AiAgentConfig.java new file mode 100644 index 00000000..fe96913c --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/config/AiAgentConfig.java @@ -0,0 +1,46 @@ +package com.erp.ai.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.web.client.RestClient; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * AI 에이전트 인프라 빈 설정. + * - aiAgentExecutor: 가상 스레드 ExecutorService (병렬 LLM 호출용) + * - aiObjectMapper: JSON 파싱용 (Spring 기본 ObjectMapper 재사용) + * - llmRestClient: LLM HTTP 호출용 RestClient + */ +@Configuration +public class AiAgentConfig { + + /** + * 가상 스레드 풀 — IO-bound LLM 호출 다수 동시 실행. + */ + @Bean(name = "aiAgentExecutor") + public ExecutorService aiAgentExecutor() { + return Executors.newVirtualThreadPerTaskExecutor(); + } + + /** + * AI 모듈 전용 ObjectMapper. 기존 Spring 의 ObjectMapper 와 별개로 명시적으로 선언. + */ + @Bean(name = "aiObjectMapper") + public ObjectMapper aiObjectMapper() { + return new ObjectMapper(); + } + + /** + * LLM HTTP 클라이언트. baseUrl 미지정 — 각 LlmClient 구현체에서 절대 URL 사용. + */ + @Bean(name = "llmRestClient") + public RestClient llmRestClient() { + return RestClient.builder() + .defaultHeader("User-Agent", "invyone-ai/1.0") + .build(); + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/config/OpenClawProperties.java b/backend-spring/src/main/java/com/erp/ai/config/OpenClawProperties.java new file mode 100644 index 00000000..2ed9beea --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/config/OpenClawProperties.java @@ -0,0 +1,17 @@ +package com.erp.ai.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +@Data +@Configuration +@ConfigurationProperties(prefix = "openclaw") +public class OpenClawProperties { + private boolean enabled = false; + private String gatewayUrl = "http://localhost:18789"; + private Duration timeout = Duration.ofSeconds(60); + private Duration healthCheckInterval = Duration.ofSeconds(30); +} diff --git a/backend-spring/src/main/java/com/erp/ai/controller/AiAgentApiKeyController.java b/backend-spring/src/main/java/com/erp/ai/controller/AiAgentApiKeyController.java new file mode 100644 index 00000000..24107bb5 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/controller/AiAgentApiKeyController.java @@ -0,0 +1,64 @@ +package com.erp.ai.controller; + +import com.erp.ai.dto.ApiKeyCreateRequest; +import com.erp.ai.model.AiAgentApiKey; +import com.erp.ai.service.AiAgentApiKeyService; +import com.erp.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/ai-agents/keys") +@RequiredArgsConstructor +@Slf4j +public class AiAgentApiKeyController { + + private final AiAgentApiKeyService apiKeyService; + + @GetMapping(value = {"", "/list"}) + public ResponseEntity>> list( + @RequestAttribute(value = "user_id", required = false) String userId, + @RequestAttribute(value = "role", required = false) String role) { + boolean isAdmin = "SUPER_ADMIN".equals(role) || "COMPANY_ADMIN".equals(role); + return ResponseEntity.ok(ApiResponse.success( + apiKeyService.list(userId != null ? userId : "system", isAdmin))); + } + + @PostMapping + public ResponseEntity>> create( + @RequestAttribute(value = "user_id", required = false) String userId, + @RequestAttribute(value = "company_code", required = false) String companyCode, + @RequestBody ApiKeyCreateRequest req) { + AiAgentApiKeyService.CreatedKey result = apiKeyService.create(req, + userId != null ? userId : "system", companyCode); + Map data = new HashMap<>(); + data.put("id", result.key().getId()); + data.put("name", result.key().getName()); + data.put("key_prefix", result.key().getKey_prefix()); + data.put("user_id", result.key().getUser_id()); + data.put("agent_id", result.key().getAgent_id()); + data.put("permissions", result.key().getPermissions()); + data.put("rate_limit", result.key().getRate_limit()); + data.put("monthly_token_limit", result.key().getMonthly_token_limit()); + data.put("status", result.key().getStatus()); + data.put("expires_at", result.key().getExpires_at()); + data.put("created_at", result.key().getCreated_at()); + data.put("plain_key", result.plainKey()); + return ResponseEntity.status(201).body( + ApiResponse.success(data, "API 키가 생성되었습니다. 키는 한 번만 표시됩니다.")); + } + + @DeleteMapping("/{id}") + public ResponseEntity> revoke( + @PathVariable long id, + @RequestAttribute(value = "user_id", required = false) String userId) { + apiKeyService.revoke(id, userId != null ? userId : "system"); + return ResponseEntity.ok(ApiResponse.success(null, "API 키가 폐기되었습니다.")); + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/controller/AiAgentController.java b/backend-spring/src/main/java/com/erp/ai/controller/AiAgentController.java new file mode 100644 index 00000000..145c1fce --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/controller/AiAgentController.java @@ -0,0 +1,70 @@ +package com.erp.ai.controller; + +import com.erp.ai.dto.AgentRequest; +import com.erp.ai.model.AiAgent; +import com.erp.ai.service.AiAgentService; +import com.erp.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/ai-agents") +@RequiredArgsConstructor +@Slf4j +public class AiAgentController { + + private final AiAgentService aiAgentService; + + @GetMapping + public ResponseEntity>> list( + @RequestAttribute(value = "company_code", required = false) String companyCode, + @RequestAttribute(value = "role", required = false) String role, + @RequestParam(required = false) String status, + @RequestParam(value = "company_code", required = false) String companyCodeParam, + @RequestParam(required = false) String search) { + boolean isSuper = "SUPER_ADMIN".equals(role); + String filter = ("*".equals(companyCodeParam) || isSuper) + ? null + : (companyCodeParam != null ? companyCodeParam : companyCode); + return ResponseEntity.ok(ApiResponse.success(aiAgentService.list(status, filter, search))); + } + + @GetMapping("/{id}") + public ResponseEntity> getById(@PathVariable long id) { + AiAgent agent = aiAgentService.getById(id); + if (agent == null) { + return ResponseEntity.status(404).body(ApiResponse.error("에이전트를 찾을 수 없습니다.")); + } + return ResponseEntity.ok(ApiResponse.success(agent)); + } + + @PostMapping + public ResponseEntity> create( + @RequestAttribute(value = "user_id", required = false) String userId, + @RequestBody AgentRequest req) { + return ResponseEntity.status(201).body( + ApiResponse.success(aiAgentService.create(req, userId != null ? userId : "system"), + "에이전트가 생성되었습니다.")); + } + + @PutMapping("/{id}") + public ResponseEntity> update( + @PathVariable long id, + @RequestBody AgentRequest req) { + AiAgent updated = aiAgentService.update(id, req); + if (updated == null) { + return ResponseEntity.status(404).body(ApiResponse.error("에이전트를 찾을 수 없습니다.")); + } + return ResponseEntity.ok(ApiResponse.success(updated, "에이전트가 수정되었습니다.")); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable long id) { + aiAgentService.delete(id); + return ResponseEntity.ok(ApiResponse.success(null, "에이전트가 삭제되었습니다.")); + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/controller/AiAgentConversationController.java b/backend-spring/src/main/java/com/erp/ai/controller/AiAgentConversationController.java new file mode 100644 index 00000000..111d19ce --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/controller/AiAgentConversationController.java @@ -0,0 +1,46 @@ +package com.erp.ai.controller; + +import com.erp.ai.service.AiAgentConversationService; +import com.erp.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/ai-agents/conversations") +@RequiredArgsConstructor +public class AiAgentConversationController { + + private final AiAgentConversationService conversationService; + + @GetMapping(value = {"", "/list"}) + public ResponseEntity> list( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int limit, + @RequestParam(value = "agent_id", required = false) Long agentId) { + Map data = conversationService.list(page, limit, agentId); + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", data.get("conversations")); + response.put("total", data.get("total")); + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}") + public ResponseEntity>> getById(@PathVariable long id) { + Map data = conversationService.getById(id); + if (data.get("conversation") == null) { + return ResponseEntity.status(404).body(ApiResponse.error("대화를 찾을 수 없습니다.")); + } + return ResponseEntity.ok(ApiResponse.success(data)); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable long id) { + conversationService.delete(id); + return ResponseEntity.ok(ApiResponse.success(null, "대화가 삭제되었습니다.")); + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/controller/AiAgentGroupController.java b/backend-spring/src/main/java/com/erp/ai/controller/AiAgentGroupController.java new file mode 100644 index 00000000..6f480d91 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/controller/AiAgentGroupController.java @@ -0,0 +1,92 @@ +package com.erp.ai.controller; + +import com.erp.ai.dto.GroupMemberRequest; +import com.erp.ai.dto.GroupRequest; +import com.erp.ai.model.AiAgentGroup; +import com.erp.ai.model.AiAgentGroupMember; +import com.erp.ai.service.AiAgentGroupService; +import com.erp.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/ai-agent-groups") +@RequiredArgsConstructor +public class AiAgentGroupController { + + private final AiAgentGroupService groupService; + + @GetMapping + public ResponseEntity>> list( + @RequestAttribute(value = "company_code", required = false) String companyCode) { + return ResponseEntity.ok(ApiResponse.success(groupService.list(companyCode))); + } + + @GetMapping("/connectors") + public ResponseEntity>>> connectors() { + return ResponseEntity.ok(ApiResponse.success(groupService.getAvailableConnectors())); + } + + @GetMapping("/{id}") + public ResponseEntity>> getById(@PathVariable long id) { + Map data = groupService.getById(id); + if (data == null) { + return ResponseEntity.status(404).body(ApiResponse.error("그룹을 찾을 수 없습니다.")); + } + return ResponseEntity.ok(ApiResponse.success(data)); + } + + @PostMapping + public ResponseEntity> create( + @RequestAttribute(value = "user_id", required = false) String userId, + @RequestBody GroupRequest req) { + return ResponseEntity.status(201).body( + ApiResponse.success(groupService.create(req, userId != null ? userId : "system"), + "멀티 에이전트 그룹이 생성되었습니다.")); + } + + @PutMapping("/{id}") + public ResponseEntity> update( + @PathVariable long id, + @RequestBody GroupRequest req) { + AiAgentGroup updated = groupService.update(id, req); + if (updated == null) { + return ResponseEntity.status(404).body(ApiResponse.error("그룹을 찾을 수 없습니다.")); + } + return ResponseEntity.ok(ApiResponse.success(updated, "그룹이 수정되었습니다.")); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable long id) { + groupService.delete(id); + return ResponseEntity.ok(ApiResponse.success(null, "그룹이 삭제되었습니다.")); + } + + // ===== 멤버 관리 ===== + + @PostMapping("/{id}/members") + public ResponseEntity> addMember( + @PathVariable long id, + @RequestBody GroupMemberRequest req) { + return ResponseEntity.status(201).body( + ApiResponse.success(groupService.addMember(id, req), "멤버가 추가되었습니다.")); + } + + @PutMapping("/members/{memberId}") + public ResponseEntity> updateMember( + @PathVariable long memberId, + @RequestBody GroupMemberRequest req) { + return ResponseEntity.ok( + ApiResponse.success(groupService.updateMember(memberId, req), "멤버가 수정되었습니다.")); + } + + @DeleteMapping("/members/{memberId}") + public ResponseEntity> removeMember(@PathVariable long memberId) { + groupService.removeMember(memberId); + return ResponseEntity.ok(ApiResponse.success(null, "멤버가 제거되었습니다.")); + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/controller/AiAgentProviderController.java b/backend-spring/src/main/java/com/erp/ai/controller/AiAgentProviderController.java new file mode 100644 index 00000000..0eab7e4c --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/controller/AiAgentProviderController.java @@ -0,0 +1,50 @@ +package com.erp.ai.controller; + +import com.erp.ai.dto.ProviderRequest; +import com.erp.ai.model.AiLlmProvider; +import com.erp.ai.service.AiAgentProviderService; +import com.erp.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/ai-agents/providers") +@RequiredArgsConstructor +@Slf4j +public class AiAgentProviderController { + + private final AiAgentProviderService providerService; + + @GetMapping(value = {"", "/list"}) + public ResponseEntity>>> list() { + return ResponseEntity.ok(ApiResponse.success(providerService.list())); + } + + @PostMapping + public ResponseEntity> create(@RequestBody ProviderRequest req) { + return ResponseEntity.status(201).body( + ApiResponse.success(providerService.create(req), "프로바이더가 추가되었습니다.")); + } + + @PutMapping("/{id}") + public ResponseEntity> update( + @PathVariable long id, + @RequestBody ProviderRequest req) { + AiLlmProvider updated = providerService.update(id, req); + if (updated == null) { + return ResponseEntity.status(404).body(ApiResponse.error("프로바이더를 찾을 수 없습니다.")); + } + return ResponseEntity.ok(ApiResponse.success(updated, "프로바이더가 수정되었습니다.")); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable long id) { + providerService.delete(id); + return ResponseEntity.ok(ApiResponse.success(null, "프로바이더가 삭제되었습니다.")); + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/controller/AiAgentUsageController.java b/backend-spring/src/main/java/com/erp/ai/controller/AiAgentUsageController.java new file mode 100644 index 00000000..9fbcec5a --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/controller/AiAgentUsageController.java @@ -0,0 +1,43 @@ +package com.erp.ai.controller; + +import com.erp.ai.dto.UsageSummaryResponse; +import com.erp.ai.service.AiAgentUsageService; +import com.erp.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/ai-agents/usage") +@RequiredArgsConstructor +public class AiAgentUsageController { + + private final AiAgentUsageService usageService; + + @GetMapping("/summary") + public ResponseEntity> summary() { + return ResponseEntity.ok(ApiResponse.success(usageService.getSummary())); + } + + @GetMapping("/logs") + public ResponseEntity> logs( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int limit) { + Map result = usageService.getLogs(page, limit); + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", result.get("logs")); + response.put("total", result.get("total")); + return ResponseEntity.ok(response); + } + + @GetMapping("/daily") + public ResponseEntity>>> daily( + @RequestParam(defaultValue = "30") int days) { + return ResponseEntity.ok(ApiResponse.success(usageService.getDailyUsage(days))); + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/controller/AiProxyController.java b/backend-spring/src/main/java/com/erp/ai/controller/AiProxyController.java new file mode 100644 index 00000000..d0cd0ddb --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/controller/AiProxyController.java @@ -0,0 +1,246 @@ +package com.erp.ai.controller; + +import com.erp.ai.client.LlmClient; +import com.erp.ai.client.LlmClientFactory; +import com.erp.ai.dto.ChatCompletionRequest; +import com.erp.ai.dto.GroupExecutionResult; +import com.erp.ai.exception.LlmClientException; +import com.erp.ai.model.AiAgentGroup; +import com.erp.ai.model.AiAgentUsageLog; +import com.erp.ai.service.AiAgentApiKeyService; +import com.erp.ai.service.AiAgentGroupService; +import com.erp.ai.service.AiAgentProviderService; +import com.erp.ai.service.AiAgentUsageService; +import com.erp.ai.service.MultiAgentExecutionEngine; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 외부 게이트웨이 (OpenAI 호환). + * vexplor openClawProxyRoutes.ts 1:1 포팅. + * + * 인증: Epic D 의 ApiKeyAuthenticationFilter 가 sk-pipe-* 검증 후 + * request attribute "ai_api_key_id" / "ai_api_key_user" 설정. + * 필터 미설치 시: 헤더 fallback 처리 (개발 단계). + */ +@Slf4j +@RestController +@RequestMapping("/api/ai/v1") +@RequiredArgsConstructor +public class AiProxyController { + + private final LlmClientFactory llmClientFactory; + private final MultiAgentExecutionEngine executionEngine; + private final AiAgentGroupService groupService; + private final AiAgentApiKeyService apiKeyService; + private final AiAgentUsageService usageService; + private final AiAgentProviderService providerService; + + /** + * POST /api/ai/v1/chat/completions — OpenAI 호환 채팅. + */ + @PostMapping("/chat/completions") + public ResponseEntity chatCompletions(@RequestBody ChatCompletionRequest req, + HttpServletRequest httpRequest) { + long startTime = System.currentTimeMillis(); + ApiKeyContext ctx = resolveApiKey(httpRequest); + + try { + Map body = new HashMap<>(); + body.put("model", req.getModel()); + body.put("messages", req.getMessages()); + if (req.getMax_tokens() != null) body.put("max_tokens", req.getMax_tokens()); + if (req.getTemperature() != null) body.put("temperature", req.getTemperature()); + + LlmClient client = llmClientFactory.pick(req.getModel()); + Map result = client.chat(body); + + // 사용량 추적 + Map usage = (Map) result.get("usage"); + int totalTokens = 0; + int promptTokens = 0; + int completionTokens = 0; + if (usage != null) { + if (usage.get("total_tokens") instanceof Number n) totalTokens = n.intValue(); + if (usage.get("prompt_tokens") instanceof Number n) promptTokens = n.intValue(); + if (usage.get("completion_tokens") instanceof Number n) completionTokens = n.intValue(); + } + long elapsed = System.currentTimeMillis() - startTime; + + AiAgentUsageLog log = new AiAgentUsageLog(); + log.setUser_id(ctx.userId); + log.setApi_key_id(ctx.keyId); + log.setProvider_name(client.providerName()); + log.setModel_name(req.getModel()); + log.setPrompt_tokens(promptTokens); + log.setCompletion_tokens(completionTokens); + log.setTotal_tokens(totalTokens); + log.setResponse_time_ms((int) elapsed); + log.setSuccess(true); + log.setRequest_path("/v1/chat/completions"); + log.setIp_address(httpRequest.getRemoteAddr()); + usageService.log(log); + + if (ctx.keyId != null && totalTokens > 0) { + apiKeyService.addTokenUsage(ctx.keyId, totalTokens); + } + return ResponseEntity.ok(result); + } catch (LlmClientException e) { + long elapsed = System.currentTimeMillis() - startTime; + AiAgentUsageLog log = new AiAgentUsageLog(); + log.setUser_id(ctx.userId); + log.setApi_key_id(ctx.keyId); + log.setModel_name(req.getModel()); + log.setResponse_time_ms((int) elapsed); + log.setSuccess(false); + log.setError_message(e.getMessage()); + log.setRequest_path("/v1/chat/completions"); + log.setIp_address(httpRequest.getRemoteAddr()); + usageService.log(log); + + return ResponseEntity.status(e.getStatusCode()) + .body(Map.of("error", Map.of("message", e.getMessage(), "type", "server_error"))); + } + } + + /** + * POST /api/ai/v1/groups/{groupId} — 멀티 에이전트 그룹 실행. + */ + @PostMapping("/groups/{groupId}") + public ResponseEntity executeGroup(@PathVariable String groupId, + @RequestBody Map body, + HttpServletRequest httpRequest) { + ApiKeyContext ctx = resolveApiKey(httpRequest); + Object messageObj = body.get("message"); + if (messageObj == null) { + return ResponseEntity.badRequest().body( + Map.of("error", Map.of("message", "message is required", "type", "invalid_request"))); + } + String message = String.valueOf(messageObj); + + long actualGroupId; + try { + actualGroupId = Long.parseLong(groupId); + } catch (NumberFormatException e) { + AiAgentGroup g = groupService.getByGroupId(groupId); + if (g == null) { + return ResponseEntity.status(404).body( + Map.of("error", Map.of("message", "Group not found", "type", "not_found"))); + } + actualGroupId = g.getId(); + } + + try { + GroupExecutionResult result = executionEngine.execute(actualGroupId, message, ctx.userId, ctx.keyId); + Map data = new HashMap<>(); + data.put("group", result.getGroupName()); + data.put("execution_mode", result.getExecutionMode()); + data.put("total_tokens", result.getTotalTokens()); + data.put("duration_ms", result.getTotalDurationMs()); + data.put("steps", result.getSteps()); + data.put("summary", result.getFinalSummary()); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", data); + return ResponseEntity.ok(response); + } catch (Exception e) { + return ResponseEntity.status(500).body( + Map.of("error", Map.of("message", e.getMessage(), "type", "execution_error"))); + } + } + + /** + * GET /api/ai/v1/groups — 사용 가능한 멀티 에이전트 그룹. + */ + @GetMapping("/groups") + public ResponseEntity> listGroups() { + List groups = groupService.list(null); + Map resp = new HashMap<>(); + resp.put("success", true); + resp.put("data", groups); + return ResponseEntity.ok(resp); + } + + /** + * GET /api/ai/v1/models — 사용 가능한 모델 목록. + */ + @GetMapping("/models") + public ResponseEntity> listModels() { + List> models = new ArrayList<>(); + try { + providerService.getActiveProviders().forEach(p -> + models.add(Map.of( + "id", p.getModel_name(), + "object", "model", + "owned_by", p.getName()))); + } catch (Exception ignored) { + } + if (models.isEmpty()) { + // fallback + models.add(Map.of("id", "claude-sonnet-4-20250514", "object", "model", "owned_by", "anthropic")); + models.add(Map.of("id", "gpt-4o", "object", "model", "owned_by", "openai")); + models.add(Map.of("id", "gpt-4o-mini", "object", "model", "owned_by", "openai")); + } + Map resp = new HashMap<>(); + resp.put("object", "list"); + resp.put("data", models); + return ResponseEntity.ok(resp); + } + + /** + * GET /api/ai/v1/health — AI 엔진 상태 확인. + */ + @GetMapping("/health") + public ResponseEntity> health() { + int activeProviders; + try { + activeProviders = providerService.getActiveProviders().size(); + } catch (Exception e) { + activeProviders = 0; + } + Map resp = new HashMap<>(); + resp.put("status", activeProviders > 0 ? "running" : "no_providers"); + resp.put("engine", "invyone-native"); + resp.put("providers", activeProviders); + return ResponseEntity.ok(resp); + } + + /** + * API key context 해석. + * 1. ApiKeyAuthenticationFilter (Epic D) 가 설정한 attribute 우선 + * 2. fallback: Authorization 헤더 직접 파싱 + DB 검증 + */ + private ApiKeyContext resolveApiKey(HttpServletRequest request) { + Long keyId = (Long) request.getAttribute("ai_api_key_id"); + String userId = (String) request.getAttribute("ai_api_key_user"); + if (keyId != null) { + return new ApiKeyContext(keyId, userId); + } + + // fallback: 직접 검증 + String header = request.getHeader("Authorization"); + if (header != null && header.startsWith("Bearer ")) { + String token = header.substring(7); + if (token.startsWith("sk-pipe-")) { + var key = apiKeyService.validateKey(token); + if (key != null) { + return new ApiKeyContext(key.getId(), key.getUser_id()); + } + } + } + // JWT 컨텍스트 fallback + Object jwtUserId = request.getAttribute("user_id"); + return new ApiKeyContext(null, jwtUserId != null ? String.valueOf(jwtUserId) : null); + } + + private record ApiKeyContext(Long keyId, String userId) {} +} diff --git a/backend-spring/src/main/java/com/erp/ai/dto/AgentExecuteRequest.java b/backend-spring/src/main/java/com/erp/ai/dto/AgentExecuteRequest.java new file mode 100644 index 00000000..86db67c7 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/dto/AgentExecuteRequest.java @@ -0,0 +1,8 @@ +package com.erp.ai.dto; + +import lombok.Data; + +@Data +public class AgentExecuteRequest { + private String message; +} diff --git a/backend-spring/src/main/java/com/erp/ai/dto/AgentRequest.java b/backend-spring/src/main/java/com/erp/ai/dto/AgentRequest.java new file mode 100644 index 00000000..c3ea5ca6 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/dto/AgentRequest.java @@ -0,0 +1,22 @@ +package com.erp.ai.dto; + +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * AI 에이전트 생성/수정 요청. + */ +@Data +public class AgentRequest { + private String agent_id; + private String name; + private String description; + private String model; + private String system_prompt; + private List tools; + private Map config; + private String company_code; + private String status; +} diff --git a/backend-spring/src/main/java/com/erp/ai/dto/ApiKeyCreateRequest.java b/backend-spring/src/main/java/com/erp/ai/dto/ApiKeyCreateRequest.java new file mode 100644 index 00000000..67cd16b2 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/dto/ApiKeyCreateRequest.java @@ -0,0 +1,16 @@ +package com.erp.ai.dto; + +import lombok.Data; + +import java.time.OffsetDateTime; +import java.util.List; + +@Data +public class ApiKeyCreateRequest { + private String name; + private Long agent_id; + private List permissions; + private Integer rate_limit; + private Long monthly_token_limit; + private OffsetDateTime expires_at; +} diff --git a/backend-spring/src/main/java/com/erp/ai/dto/ChatCompletionRequest.java b/backend-spring/src/main/java/com/erp/ai/dto/ChatCompletionRequest.java new file mode 100644 index 00000000..98bf06aa --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/dto/ChatCompletionRequest.java @@ -0,0 +1,18 @@ +package com.erp.ai.dto; + +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * OpenAI 호환 chat/completions 요청 DTO. + */ +@Data +public class ChatCompletionRequest { + private String model; + private List> messages; + private Boolean stream; + private Integer max_tokens; + private Double temperature; +} diff --git a/backend-spring/src/main/java/com/erp/ai/dto/ChatCompletionResponse.java b/backend-spring/src/main/java/com/erp/ai/dto/ChatCompletionResponse.java new file mode 100644 index 00000000..4529ec16 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/dto/ChatCompletionResponse.java @@ -0,0 +1,25 @@ +package com.erp.ai.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; +import lombok.Builder; + +import java.util.List; +import java.util.Map; + +/** + * OpenAI 호환 chat/completions 응답 DTO. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ChatCompletionResponse { + private String id; + private String object; + private Long created; + private String model; + private List> choices; + private Map usage; +} diff --git a/backend-spring/src/main/java/com/erp/ai/dto/GroupExecutionResult.java b/backend-spring/src/main/java/com/erp/ai/dto/GroupExecutionResult.java new file mode 100644 index 00000000..7c8ce4fe --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/dto/GroupExecutionResult.java @@ -0,0 +1,27 @@ +package com.erp.ai.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * 멀티 에이전트 그룹 실행 결과. + * vexplor multiAgentExecutionEngine.ts:21-29 1:1 포팅. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class GroupExecutionResult { + private long groupId; + private String groupName; + private String executionMode; + private List> steps; + private String finalSummary; + private long totalTokens; + private long totalDurationMs; +} diff --git a/backend-spring/src/main/java/com/erp/ai/dto/GroupMemberRequest.java b/backend-spring/src/main/java/com/erp/ai/dto/GroupMemberRequest.java new file mode 100644 index 00000000..db6d9fd7 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/dto/GroupMemberRequest.java @@ -0,0 +1,15 @@ +package com.erp.ai.dto; + +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class GroupMemberRequest { + private Long agent_id; + private String role_name; + private List> connectors; + private Integer execution_order; + private Map config; +} diff --git a/backend-spring/src/main/java/com/erp/ai/dto/GroupRequest.java b/backend-spring/src/main/java/com/erp/ai/dto/GroupRequest.java new file mode 100644 index 00000000..b6fa8d2a --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/dto/GroupRequest.java @@ -0,0 +1,13 @@ +package com.erp.ai.dto; + +import lombok.Data; + +@Data +public class GroupRequest { + private String name; + private String description; + /** parallel | sequential | mixed */ + private String execution_mode; + private String company_code; + private String status; +} diff --git a/backend-spring/src/main/java/com/erp/ai/dto/KnowledgeFileRequest.java b/backend-spring/src/main/java/com/erp/ai/dto/KnowledgeFileRequest.java new file mode 100644 index 00000000..313347c0 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/dto/KnowledgeFileRequest.java @@ -0,0 +1,14 @@ +package com.erp.ai.dto; + +import lombok.Data; + +@Data +public class KnowledgeFileRequest { + private String name; + private String file_name; + private String category; + private String description; + private String content; + private Long file_size; + private String mime_type; +} diff --git a/backend-spring/src/main/java/com/erp/ai/dto/ProviderRequest.java b/backend-spring/src/main/java/com/erp/ai/dto/ProviderRequest.java new file mode 100644 index 00000000..a862e1ee --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/dto/ProviderRequest.java @@ -0,0 +1,21 @@ +package com.erp.ai.dto; + +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class ProviderRequest { + private String name; + private String display_name; + /** Plain text — 서비스에서 AES-GCM 암호화 후 저장. update 시 null 이면 기존 키 유지 */ + private String api_key; + private String model_name; + private String endpoint; + private Integer priority; + private Integer max_tokens; + private BigDecimal temperature; + private BigDecimal cost_per_1k_input; + private BigDecimal cost_per_1k_output; + private Boolean is_active; +} diff --git a/backend-spring/src/main/java/com/erp/ai/dto/ScheduleRequest.java b/backend-spring/src/main/java/com/erp/ai/dto/ScheduleRequest.java new file mode 100644 index 00000000..773eb691 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/dto/ScheduleRequest.java @@ -0,0 +1,16 @@ +package com.erp.ai.dto; + +import lombok.Data; + +import java.util.Map; + +@Data +public class ScheduleRequest { + private String name; + private Long group_id; + private String cron_expression; + private String timezone; + private String input_message; + private Map notification; + private Boolean is_active; +} diff --git a/backend-spring/src/main/java/com/erp/ai/dto/UsageSummaryResponse.java b/backend-spring/src/main/java/com/erp/ai/dto/UsageSummaryResponse.java new file mode 100644 index 00000000..cc1f78d0 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/dto/UsageSummaryResponse.java @@ -0,0 +1,23 @@ +package com.erp.ai.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.Builder; + +import java.math.BigDecimal; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UsageSummaryResponse { + private long today_tokens; + private long today_requests; + private BigDecimal today_cost; + private long month_tokens; + private long month_requests; + private BigDecimal month_cost; + private int active_agents; + private int active_keys; +} diff --git a/backend-spring/src/main/java/com/erp/ai/exception/AiAgentException.java b/backend-spring/src/main/java/com/erp/ai/exception/AiAgentException.java new file mode 100644 index 00000000..ebc76776 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/exception/AiAgentException.java @@ -0,0 +1,11 @@ +package com.erp.ai.exception; + +public class AiAgentException extends RuntimeException { + public AiAgentException(String message) { + super(message); + } + + public AiAgentException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/exception/LlmClientException.java b/backend-spring/src/main/java/com/erp/ai/exception/LlmClientException.java new file mode 100644 index 00000000..50e5123c --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/exception/LlmClientException.java @@ -0,0 +1,24 @@ +package com.erp.ai.exception; + +public class LlmClientException extends RuntimeException { + private final int statusCode; + + public LlmClientException(String message) { + super(message); + this.statusCode = 500; + } + + public LlmClientException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + } + + public LlmClientException(String message, Throwable cause) { + super(message, cause); + this.statusCode = 500; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/exception/OpenClawException.java b/backend-spring/src/main/java/com/erp/ai/exception/OpenClawException.java new file mode 100644 index 00000000..db775407 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/exception/OpenClawException.java @@ -0,0 +1,29 @@ +package com.erp.ai.exception; + +/** + * OpenClaw 외부 엔진 호출 실패 시 발생하는 예외. + * enabled=false 이거나 HTTP 에러 발생 시 graceful 처리용. + */ +public class OpenClawException extends RuntimeException { + + private final int statusCode; + + public OpenClawException(String message) { + super(message); + this.statusCode = 503; + } + + public OpenClawException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + } + + public OpenClawException(String message, Throwable cause) { + super(message, cause); + this.statusCode = 503; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/health/OpenClawHealthIndicator.java b/backend-spring/src/main/java/com/erp/ai/health/OpenClawHealthIndicator.java new file mode 100644 index 00000000..24979903 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/health/OpenClawHealthIndicator.java @@ -0,0 +1,50 @@ +package com.erp.ai.health; + +import com.erp.ai.client.OpenClawClient; +import com.erp.ai.config.OpenClawProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * OpenClaw 헬스 상태를 주기적으로 갱신하는 컴포넌트. + * Spring Boot Actuator 미설치 환경을 고려해 독립 구현. + * Actuator 도입 시 HealthIndicator 구현으로 전환 가능. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OpenClawHealthIndicator { + + private final OpenClawClient openClawClient; + private final OpenClawProperties props; + + private final AtomicBoolean healthy = new AtomicBoolean(false); + + /** + * 설정된 health-check-interval 마다 헬스 상태 갱신. + * fixedRateString 은 application.yml openclaw.health-check-interval(ms 단위 Duration) 사용. + */ + @Scheduled(fixedRateString = "#{@openClawProperties.healthCheckInterval.toMillis()}") + public void checkHealth() { + if (!props.isEnabled()) { + healthy.set(false); + return; + } + boolean result = openClawClient.isHealthy(); + if (healthy.get() != result) { + log.info("OpenClaw health changed: {}", result ? "UP" : "DOWN"); + } + healthy.set(result); + } + + /** + * 현재 캐시된 헬스 상태 반환. + */ + public boolean isHealthy() { + return healthy.get(); + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentApiKeyMapper.java b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentApiKeyMapper.java new file mode 100644 index 00000000..b499afe3 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentApiKeyMapper.java @@ -0,0 +1,28 @@ +package com.erp.ai.mapper; + +import com.erp.ai.model.AiAgentApiKey; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface AiAgentApiKeyMapper { + List listAll(); + + List listByUser(@Param("userId") String userId); + + AiAgentApiKey getById(@Param("id") long id); + + AiAgentApiKey getByKeyHash(@Param("keyHash") String keyHash); + + int insert(AiAgentApiKey key); + + int delete(@Param("id") long id, @Param("userId") String userId); + + int updateLastUsed(@Param("id") long id); + + int addTokenUsage(@Param("id") long id, @Param("tokens") long tokens); + + int countActive(); +} diff --git a/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentConversationMapper.java b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentConversationMapper.java new file mode 100644 index 00000000..42ad2ed7 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentConversationMapper.java @@ -0,0 +1,28 @@ +package com.erp.ai.mapper; + +import com.erp.ai.model.AiAgentConversation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface AiAgentConversationMapper { + List list(@Param("agentId") Long agentId, + @Param("limit") int limit, + @Param("offset") int offset); + + int count(@Param("agentId") Long agentId); + + AiAgentConversation getById(@Param("id") long id); + + int insert(AiAgentConversation conv); + + int updateMeta(@Param("id") long id, + @Param("title") String title, + @Param("metadata") String metadata); + + int incrementStats(@Param("id") long id, @Param("tokens") int tokens); + + int delete(@Param("id") long id); +} diff --git a/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentGroupMapper.java b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentGroupMapper.java new file mode 100644 index 00000000..0ee374de --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentGroupMapper.java @@ -0,0 +1,24 @@ +package com.erp.ai.mapper; + +import com.erp.ai.model.AiAgentGroup; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface AiAgentGroupMapper { + List list(@Param("companyCode") String companyCode); + + AiAgentGroup getById(@Param("id") long id); + + AiAgentGroup getByGroupId(@Param("groupId") String groupId); + + int insert(AiAgentGroup group); + + int update(AiAgentGroup group); + + int softDelete(@Param("id") long id); + + int touchUpdatedAt(@Param("id") long id); +} diff --git a/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentGroupMemberMapper.java b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentGroupMemberMapper.java new file mode 100644 index 00000000..7007e951 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentGroupMemberMapper.java @@ -0,0 +1,22 @@ +package com.erp.ai.mapper; + +import com.erp.ai.model.AiAgentGroupMember; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface AiAgentGroupMemberMapper { + List listByGroupId(@Param("groupId") long groupId); + + AiAgentGroupMember getById(@Param("id") long id); + + int insert(AiAgentGroupMember member); + + int update(AiAgentGroupMember member); + + int delete(@Param("id") long id); + + int countByGroupId(@Param("groupId") long groupId); +} diff --git a/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentMapper.java b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentMapper.java new file mode 100644 index 00000000..62e68ac4 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentMapper.java @@ -0,0 +1,27 @@ +package com.erp.ai.mapper; + +import com.erp.ai.model.AiAgent; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface AiAgentMapper { + List list(Map filters); + + AiAgent getById(@Param("id") long id); + + AiAgent getByAgentId(@Param("agentId") String agentId); + + int insert(AiAgent agent); + + int update(@Param("id") long id, @Param("fields") Map fields); + + int softDelete(@Param("id") long id); + + int countActive(); + + List getActiveAgents(); +} diff --git a/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentMessageMapper.java b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentMessageMapper.java new file mode 100644 index 00000000..c892fe33 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentMessageMapper.java @@ -0,0 +1,16 @@ +package com.erp.ai.mapper; + +import com.erp.ai.model.AiAgentMessage; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface AiAgentMessageMapper { + List listByConversationId(@Param("conversationId") long conversationId); + + int insert(AiAgentMessage message); + + int deleteByConversationId(@Param("conversationId") long conversationId); +} diff --git a/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentScheduleMapper.java b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentScheduleMapper.java new file mode 100644 index 00000000..aa81f1f2 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentScheduleMapper.java @@ -0,0 +1,24 @@ +package com.erp.ai.mapper; + +import com.erp.ai.model.AiAgentSchedule; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface AiAgentScheduleMapper { + List listAll(); + + List listActive(); + + AiAgentSchedule getById(@Param("id") long id); + + int insert(AiAgentSchedule schedule); + + int update(AiAgentSchedule schedule); + + int delete(@Param("id") long id); + + int markRun(@Param("id") long id); +} diff --git a/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentUsageLogMapper.java b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentUsageLogMapper.java new file mode 100644 index 00000000..0a545be4 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/mapper/AiAgentUsageLogMapper.java @@ -0,0 +1,25 @@ +package com.erp.ai.mapper; + +import com.erp.ai.model.AiAgentUsageLog; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface AiAgentUsageLogMapper { + int insert(AiAgentUsageLog log); + + Map getTodayAggregate(); + + Map getMonthAggregate(); + + List list(@Param("limit") int limit, @Param("offset") int offset); + + int count(); + + List> getDailyUsage(@Param("days") int days); + + long getMonthTokensByApiKey(@Param("apiKeyId") long apiKeyId); +} diff --git a/backend-spring/src/main/java/com/erp/ai/mapper/AiAnalysisLogMapper.java b/backend-spring/src/main/java/com/erp/ai/mapper/AiAnalysisLogMapper.java new file mode 100644 index 00000000..e05e7376 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/mapper/AiAnalysisLogMapper.java @@ -0,0 +1,19 @@ +package com.erp.ai.mapper; + +import com.erp.ai.model.AiAnalysisLog; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.math.BigDecimal; +import java.util.List; + +@Mapper +public interface AiAnalysisLogMapper { + int insert(AiAnalysisLog log); + + List getRecentLogs(@Param("groupId") long groupId, + @Param("days") int days, + @Param("limit") int limit); + + BigDecimal getAverageAccuracy(@Param("groupId") long groupId); +} diff --git a/backend-spring/src/main/java/com/erp/ai/mapper/AiKnowledgeFileMapper.java b/backend-spring/src/main/java/com/erp/ai/mapper/AiKnowledgeFileMapper.java new file mode 100644 index 00000000..8b854017 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/mapper/AiKnowledgeFileMapper.java @@ -0,0 +1,21 @@ +package com.erp.ai.mapper; + +import com.erp.ai.model.AiKnowledgeFile; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface AiKnowledgeFileMapper { + List list(Map filters); + + AiKnowledgeFile getById(@Param("id") long id); + + int insert(AiKnowledgeFile file); + + int update(AiKnowledgeFile file); + + int delete(@Param("id") long id); +} diff --git a/backend-spring/src/main/java/com/erp/ai/mapper/AiLlmProviderMapper.java b/backend-spring/src/main/java/com/erp/ai/mapper/AiLlmProviderMapper.java new file mode 100644 index 00000000..1febeac4 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/mapper/AiLlmProviderMapper.java @@ -0,0 +1,24 @@ +package com.erp.ai.mapper; + +import com.erp.ai.model.AiLlmProvider; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface AiLlmProviderMapper { + List listAll(); + + List listActive(); + + AiLlmProvider getById(@Param("id") long id); + + AiLlmProvider getByName(@Param("name") String name); + + int insert(AiLlmProvider provider); + + int update(AiLlmProvider provider); + + int delete(@Param("id") long id); +} diff --git a/backend-spring/src/main/java/com/erp/ai/model/AiAgent.java b/backend-spring/src/main/java/com/erp/ai/model/AiAgent.java new file mode 100644 index 00000000..ac8d80cf --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/model/AiAgent.java @@ -0,0 +1,28 @@ +package com.erp.ai.model; + +import lombok.Data; + +import java.time.OffsetDateTime; + +/** + * AI 에이전트 모델 (테이블: ai_agents) + * vexplor types/aiAgent.ts:3-17 1:1 포팅. + */ +@Data +public class AiAgent { + private Long id; + private String agent_id; + private String name; + private String description; + private String model; + private String system_prompt; + /** JSONB → JSON 문자열로 보관 (서비스에서 파싱) */ + private String tools; + /** JSONB → JSON 문자열로 보관 (서비스에서 파싱) */ + private String config; + private String status; + private String company_code; + private String created_by; + private OffsetDateTime created_at; + private OffsetDateTime updated_at; +} diff --git a/backend-spring/src/main/java/com/erp/ai/model/AiAgentApiKey.java b/backend-spring/src/main/java/com/erp/ai/model/AiAgentApiKey.java new file mode 100644 index 00000000..7bef483e --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/model/AiAgentApiKey.java @@ -0,0 +1,33 @@ +package com.erp.ai.model; + +import lombok.Data; + +import java.time.OffsetDateTime; + +/** + * AI API 키 (테이블: ai_agent_api_keys) + * sk-pipe-* 형식 외부 API 인증. + */ +@Data +public class AiAgentApiKey { + private Long id; + private String name; + /** SHA-256 hex */ + private String key_hash; + /** sk-pipe-{8hex} 표시용 */ + private String key_prefix; + private String user_id; + private String company_code; + private Long agent_id; + /** JSONB string[] */ + private String permissions; + private Integer rate_limit; + private Long monthly_token_limit; + /** active | revoked */ + private String status; + private OffsetDateTime last_used_at; + private Long usage_count; + private Long total_tokens; + private OffsetDateTime expires_at; + private OffsetDateTime created_at; +} diff --git a/backend-spring/src/main/java/com/erp/ai/model/AiAgentConversation.java b/backend-spring/src/main/java/com/erp/ai/model/AiAgentConversation.java new file mode 100644 index 00000000..a624f86a --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/model/AiAgentConversation.java @@ -0,0 +1,28 @@ +package com.erp.ai.model; + +import lombok.Data; + +import java.time.OffsetDateTime; + +/** + * AI 대화 (테이블: ai_agent_conversations) + */ +@Data +public class AiAgentConversation { + private Long id; + private String conversation_id; + private Long agent_id; + private String user_id; + private Long api_key_id; + private String title; + private Integer message_count; + private Long total_tokens; + private String status; + private String metadata; + private OffsetDateTime created_at; + private OffsetDateTime updated_at; + /** JOIN: agent_name */ + private String agent_name; + /** JOIN: COALESCE(agent name, metadata->>'group_name') */ + private String display_name; +} diff --git a/backend-spring/src/main/java/com/erp/ai/model/AiAgentGroup.java b/backend-spring/src/main/java/com/erp/ai/model/AiAgentGroup.java new file mode 100644 index 00000000..3bcb90a5 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/model/AiAgentGroup.java @@ -0,0 +1,25 @@ +package com.erp.ai.model; + +import lombok.Data; + +import java.time.OffsetDateTime; + +/** + * AI 에이전트 그룹 (테이블: ai_agent_groups) + */ +@Data +public class AiAgentGroup { + private Long id; + private String group_id; + private String name; + private String description; + /** parallel | sequential | mixed */ + private String execution_mode; + private String status; + private String company_code; + private String created_by; + private OffsetDateTime created_at; + private OffsetDateTime updated_at; + /** JOIN 시 사용 — 멤버 수 */ + private Integer member_count; +} diff --git a/backend-spring/src/main/java/com/erp/ai/model/AiAgentGroupMember.java b/backend-spring/src/main/java/com/erp/ai/model/AiAgentGroupMember.java new file mode 100644 index 00000000..dd0de014 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/model/AiAgentGroupMember.java @@ -0,0 +1,28 @@ +package com.erp.ai.model; + +import lombok.Data; + +import java.time.OffsetDateTime; + +/** + * AI 에이전트 그룹 멤버 (테이블: ai_agent_group_members) + * connectors / config 는 JSONB → JSON 문자열로 보관. + */ +@Data +public class AiAgentGroupMember { + private Long id; + private Long group_id; + private Long agent_id; + private String role_name; + /** JSONB ConnectorRef[] */ + private String connectors; + private Integer execution_order; + private String config; + private OffsetDateTime created_at; + private OffsetDateTime updated_at; + + /** JOIN: ai_agents.name */ + private String agent_name; + /** JOIN: ai_agents.model */ + private String agent_model; +} diff --git a/backend-spring/src/main/java/com/erp/ai/model/AiAgentMessage.java b/backend-spring/src/main/java/com/erp/ai/model/AiAgentMessage.java new file mode 100644 index 00000000..e4821eb8 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/model/AiAgentMessage.java @@ -0,0 +1,21 @@ +package com.erp.ai.model; + +import lombok.Data; + +import java.time.OffsetDateTime; + +/** + * AI 메시지 (테이블: ai_agent_messages) + */ +@Data +public class AiAgentMessage { + private Long id; + private Long conversation_id; + /** system | user | assistant | tool */ + private String role; + private String content; + private String tool_calls; + private Integer token_count; + private String metadata; + private OffsetDateTime created_at; +} diff --git a/backend-spring/src/main/java/com/erp/ai/model/AiAgentSchedule.java b/backend-spring/src/main/java/com/erp/ai/model/AiAgentSchedule.java new file mode 100644 index 00000000..71778d35 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/model/AiAgentSchedule.java @@ -0,0 +1,30 @@ +package com.erp.ai.model; + +import lombok.Data; + +import java.time.OffsetDateTime; + +/** + * AI 스케줄 (테이블: ai_agent_schedules) + * Quartz JobStore + Cron. + */ +@Data +public class AiAgentSchedule { + private Long id; + private String name; + private Long group_id; + private String cron_expression; + private String timezone; + private String input_message; + private String notification; + private Boolean is_active; + private OffsetDateTime last_run_at; + private Long run_count; + private String company_code; + private String created_by; + private OffsetDateTime created_at; + private OffsetDateTime updated_at; + + /** JOIN: ai_agent_groups.name */ + private String group_name; +} diff --git a/backend-spring/src/main/java/com/erp/ai/model/AiAgentUsageLog.java b/backend-spring/src/main/java/com/erp/ai/model/AiAgentUsageLog.java new file mode 100644 index 00000000..91f0c1e5 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/model/AiAgentUsageLog.java @@ -0,0 +1,31 @@ +package com.erp.ai.model; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +/** + * AI 사용량 로그 (테이블: ai_agent_usage_logs) + */ +@Data +public class AiAgentUsageLog { + private Long id; + private String user_id; + private Long api_key_id; + private Long agent_id; + private Long conversation_id; + private String provider_name; + private String model_name; + private Integer prompt_tokens; + private Integer completion_tokens; + private Integer total_tokens; + private BigDecimal cost_usd; + private Integer response_time_ms; + private Boolean success; + private String error_message; + private String request_path; + private String ip_address; + private String company_code; + private OffsetDateTime created_at; +} diff --git a/backend-spring/src/main/java/com/erp/ai/model/AiAnalysisLog.java b/backend-spring/src/main/java/com/erp/ai/model/AiAnalysisLog.java new file mode 100644 index 00000000..5948dcb9 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/model/AiAnalysisLog.java @@ -0,0 +1,29 @@ +package com.erp.ai.model; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +/** + * AI 분석 이력 (테이블: ai_analysis_logs) + */ +@Data +public class AiAnalysisLog { + private Long id; + private Long group_id; + private Long agent_id; + private Long schedule_id; + /** manual | api | schedule */ + private String execution_type; + private String input_message; + private String analysis_result; + private String prediction; + private String actual_result; + private BigDecimal accuracy_score; + private Integer tokens_used; + private Integer duration_ms; + private String metadata; + private String company_code; + private OffsetDateTime created_at; +} diff --git a/backend-spring/src/main/java/com/erp/ai/model/AiKnowledgeFile.java b/backend-spring/src/main/java/com/erp/ai/model/AiKnowledgeFile.java new file mode 100644 index 00000000..ee375fe9 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/model/AiKnowledgeFile.java @@ -0,0 +1,24 @@ +package com.erp.ai.model; + +import lombok.Data; + +import java.time.OffsetDateTime; + +/** + * AI 지식 파일 (테이블: ai_knowledge_files) + */ +@Data +public class AiKnowledgeFile { + private Long id; + private String name; + private String file_name; + private String category; + private String description; + private String content; + private Long file_size; + private String mime_type; + private String company_code; + private String created_by; + private OffsetDateTime created_at; + private OffsetDateTime updated_at; +} diff --git a/backend-spring/src/main/java/com/erp/ai/model/AiLlmProvider.java b/backend-spring/src/main/java/com/erp/ai/model/AiLlmProvider.java new file mode 100644 index 00000000..f92a95d3 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/model/AiLlmProvider.java @@ -0,0 +1,30 @@ +package com.erp.ai.model; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +/** + * LLM 프로바이더 (테이블: ai_llm_providers) + * api_key_encrypted 는 AES-GCM 암호화 base64 문자열. + */ +@Data +public class AiLlmProvider { + private Long id; + /** anthropic | openai | google | deepseek | ollama */ + private String name; + private String display_name; + private String api_key_encrypted; + private String model_name; + private String endpoint; + private Integer priority; + private Integer max_tokens; + private BigDecimal temperature; + private BigDecimal cost_per_1k_input; + private BigDecimal cost_per_1k_output; + private Boolean is_active; + private String config; + private OffsetDateTime created_at; + private OffsetDateTime updated_at; +} diff --git a/backend-spring/src/main/java/com/erp/ai/scheduler/MultiAgentExecutionJob.java b/backend-spring/src/main/java/com/erp/ai/scheduler/MultiAgentExecutionJob.java new file mode 100644 index 00000000..1a056ec1 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/scheduler/MultiAgentExecutionJob.java @@ -0,0 +1,87 @@ +package com.erp.ai.scheduler; + +import com.erp.ai.dto.GroupExecutionResult; +import com.erp.ai.mapper.AiAgentScheduleMapper; +import com.erp.ai.model.AiAgentSchedule; +import com.erp.ai.service.MultiAgentExecutionEngine; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.Map; + +/** + * Quartz Job — AI 스케줄 실행. + * architecture §8.2. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MultiAgentExecutionJob implements Job { + + @Autowired + private MultiAgentExecutionEngine executionEngine; + + @Autowired + private AiAgentScheduleMapper scheduleMapper; + + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + long scheduleId = context.getMergedJobDataMap().getLong("scheduleId"); + AiAgentSchedule schedule = scheduleMapper.getById(scheduleId); + if (schedule == null) { + log.warn("스케줄 없음: {}", scheduleId); + return; + } + log.info("AI 스케줄 실행: {} (ID: {})", schedule.getName(), schedule.getId()); + + try { + GroupExecutionResult result = executionEngine.execute( + schedule.getGroup_id(), + schedule.getInput_message(), + schedule.getCreated_by(), + null); + scheduleMapper.markRun(schedule.getId()); + sendNotification(schedule, result); + log.info("AI 스케줄 완료: {} - {} tokens", schedule.getName(), result.getTotalTokens()); + } catch (Exception e) { + log.error("AI 스케줄 실패: {} - {}", schedule.getName(), e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + private void sendNotification(AiAgentSchedule schedule, GroupExecutionResult result) { + if (schedule.getNotification() == null) return; + try { + // notification 은 JSON 문자열 — 단순 처리: webhook 만 RestClient.post + // 시스템 공지/이메일은 invyone 서비스 호출 (Phase 2). + String n = schedule.getNotification(); + if (n.contains("\"webhook\"")) { + int idx = n.indexOf("\"webhook\""); + int colon = n.indexOf(':', idx); + int quoteOpen = n.indexOf('"', colon + 1); + int quoteClose = n.indexOf('"', quoteOpen + 1); + if (quoteOpen > 0 && quoteClose > quoteOpen) { + String webhook = n.substring(quoteOpen + 1, quoteClose); + if (webhook.startsWith("http")) { + RestClient.create().post().uri(webhook) + .body(Map.of("text", + "[" + schedule.getName() + "] 실행 완료\n\n" + + (result.getFinalSummary().length() > 1000 + ? result.getFinalSummary().substring(0, 1000) + : result.getFinalSummary()))) + .retrieve() + .toBodilessEntity(); + } + } + } + } catch (Exception e) { + log.warn("알림 발송 실패: {}", e.getMessage()); + } + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/security/AiApiKeyAuthFilter.java b/backend-spring/src/main/java/com/erp/ai/security/AiApiKeyAuthFilter.java new file mode 100644 index 00000000..fae359bf --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/security/AiApiKeyAuthFilter.java @@ -0,0 +1,146 @@ +package com.erp.ai.security; + +import com.erp.ai.model.AiAgentApiKey; +import com.erp.ai.service.AiAgentApiKeyService; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; + +/** + * /api/ai/v1/** 엔드포인트에 대한 API 키 인증 필터. + * Authorization: Bearer sk-pipe-{hex} 형식의 키를 검증합니다. + * SubdomainResolverFilter 뒤, JwtAuthenticationFilter 앞에 위치합니다. + */ +@Slf4j +@RequiredArgsConstructor +public class AiApiKeyAuthFilter extends OncePerRequestFilter { + + private static final String API_KEY_PREFIX = "sk-pipe-"; + private static final String AI_API_PATH_PATTERN = "/api/ai/v1/**"; + private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher(); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final AiAgentApiKeyService aiAgentApiKeyService; + + /** + * /api/ai/v1/** 외 경로는 이 필터를 건너뜁니다. + */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return !PATH_MATCHER.match(AI_API_PATH_PATTERN, path); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + // Authorization 헤더가 없거나 sk-pipe- 로 시작하지 않으면 JWT 필터로 패스 + if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer " + API_KEY_PREFIX)) { + filterChain.doFilter(request, response); + return; + } + + // "Bearer " 제거 후 실제 키 값 추출 + String rawKey = authHeader.substring(7); // "Bearer ".length() == 7 + + // 키 검증 + last_used_at 갱신 (Epic C 서비스가 SHA-256 + 만료체크 + last_used 갱신을 수행) + AiAgentApiKey apiKey; + try { + apiKey = aiAgentApiKeyService.validateKey(rawKey); + } catch (Exception e) { + log.error("[AiApiKeyAuthFilter] Key validation failure", e); + sendError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", "Internal server error"); + return; + } + + if (apiKey == null) { + log.warn("[AiApiKeyAuthFilter] Invalid or expired API key. prefix={}", + rawKey.length() > 16 ? rawKey.substring(0, 16) + "..." : rawKey); + sendError(response, HttpServletResponse.SC_UNAUTHORIZED, + "INVALID_API_KEY", "Invalid or expired API key"); + return; + } + + // 만료일 이중 검증 (서비스에서 처리하지 않을 경우 대비) + if (apiKey.getExpires_at() != null && apiKey.getExpires_at().isBefore(OffsetDateTime.now())) { + log.warn("[AiApiKeyAuthFilter] API key expired. keyId={}", apiKey.getId()); + sendError(response, HttpServletResponse.SC_UNAUTHORIZED, + "API_KEY_EXPIRED", "API key has expired"); + return; + } + + // 월간 토큰 한도 체크 + if (apiKey.getMonthly_token_limit() != null + && apiKey.getMonthly_token_limit() > 0 + && apiKey.getTotal_tokens() != null + && apiKey.getTotal_tokens() >= apiKey.getMonthly_token_limit()) { + log.warn("[AiApiKeyAuthFilter] Monthly token limit exceeded. keyId={}", apiKey.getId()); + sendError(response, 429, + "TOKEN_LIMIT_EXCEEDED", "Monthly token limit exceeded"); + return; + } + + // Request attribute 설정 (JwtAuthenticationFilter 와 동일한 키 사용) + request.setAttribute("user_id", apiKey.getUser_id()); + request.setAttribute("company_code", apiKey.getCompany_code()); + request.setAttribute("role", "API_KEY_USER"); + request.setAttribute("user_type", "API_KEY_USER"); + request.setAttribute("api_key_id", apiKey.getId()); + // AiProxyController 가 사용하는 attribute 이름과 일치시킴 + request.setAttribute("ai_api_key_id", apiKey.getId()); + request.setAttribute("ai_api_key_user", apiKey.getUser_id()); + + // SecurityContext 설정 + List authorities = List.of( + new SimpleGrantedAuthority("ROLE_API_KEY_USER") + ); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(apiKey.getUser_id(), null, authorities); + SecurityContextHolder.getContext().setAuthentication(authentication); + + log.debug("[AiApiKeyAuthFilter] Authenticated via API key. keyId={} userId={} companyCode={}", + apiKey.getId(), apiKey.getUser_id(), apiKey.getCompany_code()); + + filterChain.doFilter(request, response); + } + + /** + * 401/429 JSON 오류 응답 전송. + */ + private void sendError(HttpServletResponse response, int status, String code, String message) + throws IOException { + response.setStatus(status); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + + Map body = Map.of( + "error", Map.of( + "code", code, + "message", message + ) + ); + response.getWriter().write(OBJECT_MAPPER.writeValueAsString(body)); + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/service/AiAgentApiKeyService.java b/backend-spring/src/main/java/com/erp/ai/service/AiAgentApiKeyService.java new file mode 100644 index 00000000..c4c481f2 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/service/AiAgentApiKeyService.java @@ -0,0 +1,132 @@ +package com.erp.ai.service; + +import com.erp.ai.dto.ApiKeyCreateRequest; +import com.erp.ai.exception.AiAgentException; +import com.erp.ai.mapper.AiAgentApiKeyMapper; +import com.erp.ai.model.AiAgentApiKey; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.OffsetDateTime; +import java.util.HexFormat; +import java.util.List; + +/** + * AI API 키 관리 서비스. + * vexplor aiAgentApiKeyService.ts 1:1 포팅. + * - sk-pipe-{64hex} 발급 + * - SHA-256 hash 저장 (plain key는 1회 반환 후 폐기) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiAgentApiKeyService { + + private final AiAgentApiKeyMapper apiKeyMapper; + private final SecureRandom secureRandom = new SecureRandom(); + + @Qualifier("aiObjectMapper") + private final ObjectMapper objectMapper; + + public List list(String userId, boolean isAdmin) { + return isAdmin ? apiKeyMapper.listAll() : apiKeyMapper.listByUser(userId); + } + + public AiAgentApiKey getById(long id) { + return apiKeyMapper.getById(id); + } + + /** {key, plainKey} 쌍 반환 */ + public CreatedKey create(ApiKeyCreateRequest req, String userId, String companyCode) { + GeneratedKey gen = generateKey(); + + AiAgentApiKey key = new AiAgentApiKey(); + key.setName(req.getName()); + key.setKey_hash(gen.hash); + key.setKey_prefix(gen.prefix); + key.setUser_id(userId); + key.setCompany_code(companyCode); + key.setAgent_id(req.getAgent_id()); + key.setPermissions(toJsonString(req.getPermissions() != null ? req.getPermissions() : List.of("chat"))); + key.setRate_limit(req.getRate_limit() != null ? req.getRate_limit() : 60); + key.setMonthly_token_limit(req.getMonthly_token_limit() != null ? req.getMonthly_token_limit() : 1_000_000L); + key.setExpires_at(req.getExpires_at()); + + apiKeyMapper.insert(key); + AiAgentApiKey saved = apiKeyMapper.getById(key.getId()); + log.info("API 키 생성: {}... (by {})", gen.prefix, userId); + return new CreatedKey(saved, gen.plainKey); + } + + @Transactional + public void revoke(long id, String userId) { + apiKeyMapper.delete(id, userId); + log.info("API 키 삭제: id={} (by {})", id, userId); + } + + /** + * 외부 API 키 검증 — SHA-256 매칭 + 만료 체크 + last_used 갱신. + * @return null = 무효, 객체 = 유효 + */ + @Transactional + public AiAgentApiKey validateKey(String plainKey) { + if (plainKey == null || !plainKey.startsWith("sk-pipe-")) return null; + String hash = sha256Hex(plainKey); + AiAgentApiKey key = apiKeyMapper.getByKeyHash(hash); + if (key == null) return null; + if (key.getExpires_at() != null && key.getExpires_at().isBefore(OffsetDateTime.now())) { + return null; + } + apiKeyMapper.updateLastUsed(key.getId()); + return key; + } + + public void addTokenUsage(long keyId, long tokens) { + apiKeyMapper.addTokenUsage(keyId, tokens); + } + + public int countActive() { + return apiKeyMapper.countActive(); + } + + private GeneratedKey generateKey() { + byte[] randomBytes = new byte[32]; + secureRandom.nextBytes(randomBytes); + String hex = HexFormat.of().formatHex(randomBytes); + String plainKey = "sk-pipe-" + hex; + String hash = sha256Hex(plainKey); + String prefix = plainKey.substring(0, 16); + return new GeneratedKey(plainKey, hash, prefix); + } + + private String sha256Hex(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new AiAgentException("SHA-256 미지원", e); + } + } + + private String toJsonString(Object value) { + if (value == null) return null; + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new AiAgentException("JSON 직렬화 실패", e); + } + } + + public record CreatedKey(AiAgentApiKey key, String plainKey) {} + private record GeneratedKey(String plainKey, String hash, String prefix) {} +} diff --git a/backend-spring/src/main/java/com/erp/ai/service/AiAgentConversationService.java b/backend-spring/src/main/java/com/erp/ai/service/AiAgentConversationService.java new file mode 100644 index 00000000..4134362c --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/service/AiAgentConversationService.java @@ -0,0 +1,99 @@ +package com.erp.ai.service; + +import com.erp.ai.mapper.AiAgentConversationMapper; +import com.erp.ai.mapper.AiAgentMessageMapper; +import com.erp.ai.model.AiAgentConversation; +import com.erp.ai.model.AiAgentMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * AI 대화 모니터링 서비스. + * vexplor aiAgentConversationService.ts 1:1 포팅. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiAgentConversationService { + + private final AiAgentConversationMapper conversationMapper; + private final AiAgentMessageMapper messageMapper; + + public Map list(int page, int limit, Long agentId) { + int offset = (page - 1) * limit; + List conversations = conversationMapper.list(agentId, limit, offset); + int total = conversationMapper.count(agentId); + Map result = new HashMap<>(); + result.put("conversations", conversations); + result.put("total", total); + return result; + } + + public Map getById(long id) { + AiAgentConversation conv = conversationMapper.getById(id); + List messages = conv != null + ? messageMapper.listByConversationId(id) + : List.of(); + Map result = new HashMap<>(); + result.put("conversation", conv); + result.put("messages", messages); + return result; + } + + @Transactional + public AiAgentConversation createConversation(Long agentId, String userId, Long apiKeyId) { + AiAgentConversation conv = new AiAgentConversation(); + conv.setConversation_id("conv-" + UUID.randomUUID()); + conv.setAgent_id(agentId); + conv.setUser_id(userId); + conv.setApi_key_id(apiKeyId); + conv.setMetadata("{}"); + conversationMapper.insert(conv); + return conversationMapper.getById(conv.getId()); + } + + @Transactional + public void updateMeta(long id, String title, String metadataJson) { + conversationMapper.updateMeta(id, title, metadataJson); + } + + @Transactional + public AiAgentMessage addMessage(long conversationId, String role, String content, + int tokenCount, String toolCallsJson) { + AiAgentMessage msg = new AiAgentMessage(); + msg.setConversation_id(conversationId); + msg.setRole(role); + msg.setContent(content); + msg.setToken_count(tokenCount); + msg.setTool_calls(toolCallsJson); + messageMapper.insert(msg); + conversationMapper.incrementStats(conversationId, tokenCount); + return msg; + } + + @Transactional + public AiAgentMessage addMessageWithMetadata(long conversationId, String role, String content, + int tokenCount, String metadataJson) { + AiAgentMessage msg = new AiAgentMessage(); + msg.setConversation_id(conversationId); + msg.setRole(role); + msg.setContent(content); + msg.setToken_count(tokenCount); + msg.setMetadata(metadataJson); + messageMapper.insert(msg); + conversationMapper.incrementStats(conversationId, tokenCount); + return msg; + } + + @Transactional + public void delete(long id) { + conversationMapper.delete(id); + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/service/AiAgentGroupService.java b/backend-spring/src/main/java/com/erp/ai/service/AiAgentGroupService.java new file mode 100644 index 00000000..99044412 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/service/AiAgentGroupService.java @@ -0,0 +1,161 @@ +package com.erp.ai.service; + +import com.erp.ai.dto.GroupMemberRequest; +import com.erp.ai.dto.GroupRequest; +import com.erp.ai.exception.AiAgentException; +import com.erp.ai.mapper.AiAgentGroupMapper; +import com.erp.ai.mapper.AiAgentGroupMemberMapper; +import com.erp.ai.model.AiAgentGroup; +import com.erp.ai.model.AiAgentGroupMember; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * AI 에이전트 그룹 서비스. + * vexplor aiAgentGroupService.ts 1:1 포팅. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiAgentGroupService { + + private final AiAgentGroupMapper groupMapper; + private final AiAgentGroupMemberMapper memberMapper; + + @Qualifier("aiObjectMapper") + private final ObjectMapper objectMapper; + + public List list(String companyCode) { + return groupMapper.list(companyCode); + } + + public Map getById(long id) { + AiAgentGroup group = groupMapper.getById(id); + if (group == null) return null; + List members = memberMapper.listByGroupId(id); + Map result = new HashMap<>(); + result.put("id", group.getId()); + result.put("group_id", group.getGroup_id()); + result.put("name", group.getName()); + result.put("description", group.getDescription()); + result.put("execution_mode", group.getExecution_mode()); + result.put("status", group.getStatus()); + result.put("company_code", group.getCompany_code()); + result.put("created_by", group.getCreated_by()); + result.put("created_at", group.getCreated_at()); + result.put("updated_at", group.getUpdated_at()); + result.put("members", members); + return result; + } + + public AiAgentGroup getEntityById(long id) { + AiAgentGroup g = groupMapper.getById(id); + if (g == null) return null; + return g; + } + + public AiAgentGroup getByGroupId(String groupId) { + return groupMapper.getByGroupId(groupId); + } + + public List listMembers(long groupId) { + return memberMapper.listByGroupId(groupId); + } + + @Transactional + public AiAgentGroup create(GroupRequest req, String userId) { + AiAgentGroup group = new AiAgentGroup(); + group.setGroup_id("group-" + Long.toString(System.currentTimeMillis(), 36)); + group.setName(req.getName()); + group.setDescription(req.getDescription()); + group.setExecution_mode(req.getExecution_mode() != null ? req.getExecution_mode() : "mixed"); + group.setStatus("active"); + group.setCompany_code(req.getCompany_code()); + group.setCreated_by(userId); + groupMapper.insert(group); + log.info("멀티 에이전트 그룹 생성: {} (by {})", req.getName(), userId); + return groupMapper.getById(group.getId()); + } + + @Transactional + public AiAgentGroup update(long id, GroupRequest req) { + AiAgentGroup existing = groupMapper.getById(id); + if (existing == null) return null; + + AiAgentGroup g = new AiAgentGroup(); + g.setId(id); + g.setName(req.getName()); + g.setDescription(req.getDescription()); + g.setExecution_mode(req.getExecution_mode()); + g.setStatus(req.getStatus()); + groupMapper.update(g); + return groupMapper.getById(id); + } + + @Transactional + public void delete(long id) { + groupMapper.softDelete(id); + } + + // ===== 멤버 관리 ===== + + @Transactional + public AiAgentGroupMember addMember(long groupId, GroupMemberRequest req) { + if (req.getAgent_id() == null) throw new AiAgentException("agent_id 가 필요합니다."); + AiAgentGroupMember member = new AiAgentGroupMember(); + member.setGroup_id(groupId); + member.setAgent_id(req.getAgent_id()); + member.setRole_name(req.getRole_name()); + member.setConnectors(toJsonString(req.getConnectors() != null ? req.getConnectors() : List.of())); + member.setExecution_order(req.getExecution_order() != null ? req.getExecution_order() : 1); + member.setConfig(toJsonString(req.getConfig() != null ? req.getConfig() : Map.of())); + memberMapper.insert(member); + groupMapper.touchUpdatedAt(groupId); + return memberMapper.getById(member.getId()); + } + + @Transactional + public AiAgentGroupMember updateMember(long memberId, GroupMemberRequest req) { + AiAgentGroupMember m = new AiAgentGroupMember(); + m.setId(memberId); + m.setRole_name(req.getRole_name()); + if (req.getConnectors() != null) m.setConnectors(toJsonString(req.getConnectors())); + m.setExecution_order(req.getExecution_order()); + if (req.getConfig() != null) m.setConfig(toJsonString(req.getConfig())); + memberMapper.update(m); + return memberMapper.getById(memberId); + } + + @Transactional + public void removeMember(long memberId) { + memberMapper.delete(memberId); + } + + /** + * 사용 가능한 커넥터 목록. + * vexplor 의 try/catch + empty fallback 동일. + * invyone에는 external_db_connections 등 테이블이 없어 빈 배열 반환. + */ + public List> getAvailableConnectors() { + // architecture §3.13: invyone에 부재 — 빈 배열 fallback. + return List.of(); + } + + private String toJsonString(Object value) { + if (value == null) return null; + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new AiAgentException("JSON 직렬화 실패", e); + } + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/service/AiAgentProviderService.java b/backend-spring/src/main/java/com/erp/ai/service/AiAgentProviderService.java new file mode 100644 index 00000000..dfe9c7bc --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/service/AiAgentProviderService.java @@ -0,0 +1,126 @@ +package com.erp.ai.service; + +import com.erp.ai.dto.ProviderRequest; +import com.erp.ai.exception.AiAgentException; +import com.erp.ai.mapper.AiLlmProviderMapper; +import com.erp.ai.model.AiLlmProvider; +import com.erp.ai.util.AesGcmCipher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * LLM 프로바이더 관리 서비스. + * vexplor aiAgentProviderService.ts 1:1 포팅. + * - LLM API 키는 AES-GCM 암호화 저장 + * - 목록 조회 시 키 마스킹 (****+last4) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiAgentProviderService { + + private final AiLlmProviderMapper providerMapper; + private final AesGcmCipher cipher; + + /** 키 마스킹된 목록 */ + public List> list() { + return providerMapper.listAll().stream() + .map(this::toMaskedMap) + .toList(); + } + + public AiLlmProvider getById(long id) { + return providerMapper.getById(id); + } + + public List getActiveProviders() { + return providerMapper.listActive(); + } + + @Transactional + public AiLlmProvider create(ProviderRequest req) { + if (req.getApi_key() == null || req.getApi_key().isBlank()) { + throw new AiAgentException("api_key 가 필요합니다."); + } + AiLlmProvider provider = new AiLlmProvider(); + provider.setName(req.getName()); + provider.setDisplay_name(req.getDisplay_name()); + provider.setApi_key_encrypted(cipher.encrypt(req.getApi_key())); + provider.setModel_name(req.getModel_name()); + provider.setEndpoint(req.getEndpoint()); + provider.setPriority(req.getPriority()); + provider.setMax_tokens(req.getMax_tokens()); + provider.setTemperature(req.getTemperature()); + provider.setCost_per_1k_input(req.getCost_per_1k_input()); + provider.setCost_per_1k_output(req.getCost_per_1k_output()); + provider.setIs_active(req.getIs_active() != null ? req.getIs_active() : Boolean.TRUE); + + providerMapper.insert(provider); + log.info("LLM 프로바이더 추가: {} ({})", req.getName(), req.getModel_name()); + return providerMapper.getById(provider.getId()); + } + + @Transactional + public AiLlmProvider update(long id, ProviderRequest req) { + AiLlmProvider existing = providerMapper.getById(id); + if (existing == null) return null; + + AiLlmProvider provider = new AiLlmProvider(); + provider.setId(id); + provider.setDisplay_name(req.getDisplay_name()); + if (req.getApi_key() != null && !req.getApi_key().isBlank()) { + provider.setApi_key_encrypted(cipher.encrypt(req.getApi_key())); + } + provider.setModel_name(req.getModel_name()); + provider.setEndpoint(req.getEndpoint()); + provider.setPriority(req.getPriority()); + provider.setMax_tokens(req.getMax_tokens()); + provider.setTemperature(req.getTemperature()); + provider.setCost_per_1k_input(req.getCost_per_1k_input()); + provider.setCost_per_1k_output(req.getCost_per_1k_output()); + provider.setIs_active(req.getIs_active()); + + providerMapper.update(provider); + return providerMapper.getById(id); + } + + @Transactional + public void delete(long id) { + providerMapper.delete(id); + } + + /** 복호화된 키 반환 (서비스 내부에서만 사용) */ + public String getDecryptedKey(long id) { + AiLlmProvider p = providerMapper.getById(id); + if (p == null) return null; + return cipher.decrypt(p.getApi_key_encrypted()); + } + + private Map toMaskedMap(AiLlmProvider p) { + Map m = new HashMap<>(); + m.put("id", p.getId()); + m.put("name", p.getName()); + m.put("display_name", p.getDisplay_name()); + m.put("model_name", p.getModel_name()); + m.put("endpoint", p.getEndpoint()); + m.put("priority", p.getPriority()); + m.put("max_tokens", p.getMax_tokens()); + m.put("temperature", p.getTemperature()); + m.put("cost_per_1k_input", p.getCost_per_1k_input()); + m.put("cost_per_1k_output", p.getCost_per_1k_output()); + m.put("is_active", p.getIs_active()); + m.put("config", p.getConfig()); + m.put("created_at", p.getCreated_at()); + m.put("updated_at", p.getUpdated_at()); + // 마스킹: 마지막 4글자만 노출 + String enc = p.getApi_key_encrypted(); + m.put("api_key_masked", enc != null && enc.length() >= 4 ? "****" + enc.substring(enc.length() - 4) : ""); + return m; + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/service/AiAgentService.java b/backend-spring/src/main/java/com/erp/ai/service/AiAgentService.java new file mode 100644 index 00000000..efd323f9 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/service/AiAgentService.java @@ -0,0 +1,112 @@ +package com.erp.ai.service; + +import com.erp.ai.dto.AgentRequest; +import com.erp.ai.exception.AiAgentException; +import com.erp.ai.mapper.AiAgentMapper; +import com.erp.ai.model.AiAgent; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * AI 에이전트 CRUD 서비스. + * vexplor aiAgentService.ts 1:1 포팅. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiAgentService { + + private final AiAgentMapper agentMapper; + + @Qualifier("aiObjectMapper") + private final ObjectMapper objectMapper; + + public List list(String status, String companyCode, String search) { + Map filters = new HashMap<>(); + if (status != null) filters.put("status", status); + if (companyCode != null) filters.put("company_code", companyCode); + if (search != null && !search.isBlank()) filters.put("search", "%" + search + "%"); + return agentMapper.list(filters); + } + + public AiAgent getById(long id) { + return agentMapper.getById(id); + } + + public AiAgent getByAgentId(String agentId) { + return agentMapper.getByAgentId(agentId); + } + + @Transactional + public AiAgent create(AgentRequest req, String userId) { + if (req.getAgent_id() == null || req.getAgent_id().isBlank()) { + throw new AiAgentException("agent_id 가 필요합니다."); + } + if (agentMapper.getByAgentId(req.getAgent_id()) != null) { + throw new AiAgentException("이미 존재하는 에이전트 ID입니다."); + } + + AiAgent agent = new AiAgent(); + agent.setAgent_id(req.getAgent_id()); + agent.setName(req.getName()); + agent.setDescription(req.getDescription()); + agent.setModel(req.getModel()); + agent.setSystem_prompt(req.getSystem_prompt()); + agent.setTools(toJsonString(req.getTools())); + agent.setConfig(toJsonString(req.getConfig())); + agent.setStatus(req.getStatus() != null ? req.getStatus() : "active"); + agent.setCompany_code(req.getCompany_code()); + agent.setCreated_by(userId); + + agentMapper.insert(agent); + log.info("에이전트 생성: {} (by {})", req.getAgent_id(), userId); + return agentMapper.getById(agent.getId()); + } + + @Transactional + public AiAgent update(long id, AgentRequest req) { + AiAgent existing = agentMapper.getById(id); + if (existing == null) return null; + + Map fields = new HashMap<>(); + if (req.getName() != null) fields.put("name", req.getName()); + if (req.getDescription() != null) fields.put("description", req.getDescription()); + if (req.getModel() != null) fields.put("model", req.getModel()); + if (req.getSystem_prompt() != null) fields.put("system_prompt", req.getSystem_prompt()); + if (req.getTools() != null) fields.put("tools", toJsonString(req.getTools())); + if (req.getConfig() != null) fields.put("config", toJsonString(req.getConfig())); + if (req.getStatus() != null) fields.put("status", req.getStatus()); + + if (!fields.isEmpty()) { + agentMapper.update(id, fields); + } + return agentMapper.getById(id); + } + + @Transactional + public void delete(long id) { + agentMapper.softDelete(id); + } + + public List getActiveAgents() { + return agentMapper.getActiveAgents(); + } + + private String toJsonString(Object value) { + if (value == null) return null; + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new AiAgentException("JSON 직렬화 실패", e); + } + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/service/AiAgentUsageService.java b/backend-spring/src/main/java/com/erp/ai/service/AiAgentUsageService.java new file mode 100644 index 00000000..6a559bae --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/service/AiAgentUsageService.java @@ -0,0 +1,83 @@ +package com.erp.ai.service; + +import com.erp.ai.dto.UsageSummaryResponse; +import com.erp.ai.mapper.AiAgentApiKeyMapper; +import com.erp.ai.mapper.AiAgentMapper; +import com.erp.ai.mapper.AiAgentUsageLogMapper; +import com.erp.ai.model.AiAgentUsageLog; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * AI 사용량/비용 집계 서비스. + * vexplor aiAgentUsageService.ts 1:1 포팅. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiAgentUsageService { + + private final AiAgentUsageLogMapper usageMapper; + private final AiAgentMapper agentMapper; + private final AiAgentApiKeyMapper apiKeyMapper; + + public void log(AiAgentUsageLog data) { + if (data.getSuccess() == null) data.setSuccess(true); + usageMapper.insert(data); + } + + public UsageSummaryResponse getSummary() { + Map today = usageMapper.getTodayAggregate(); + Map month = usageMapper.getMonthAggregate(); + int activeAgents = agentMapper.countActive(); + int activeKeys = apiKeyMapper.countActive(); + + return UsageSummaryResponse.builder() + .today_tokens(toLong(today.get("tokens"))) + .today_requests(toLong(today.get("requests"))) + .today_cost(toBigDecimal(today.get("cost"))) + .month_tokens(toLong(month.get("tokens"))) + .month_requests(toLong(month.get("requests"))) + .month_cost(toBigDecimal(month.get("cost"))) + .active_agents(activeAgents) + .active_keys(activeKeys) + .build(); + } + + public Map getLogs(int page, int limit) { + int offset = (page - 1) * limit; + List logs = usageMapper.list(limit, offset); + int total = usageMapper.count(); + Map result = new HashMap<>(); + result.put("logs", logs); + result.put("total", total); + return result; + } + + public List> getDailyUsage(int days) { + return usageMapper.getDailyUsage(days); + } + + public long getMonthTokensByApiKey(long apiKeyId) { + return usageMapper.getMonthTokensByApiKey(apiKeyId); + } + + private long toLong(Object v) { + if (v == null) return 0L; + if (v instanceof Number n) return n.longValue(); + return Long.parseLong(v.toString()); + } + + private BigDecimal toBigDecimal(Object v) { + if (v == null) return BigDecimal.ZERO; + if (v instanceof BigDecimal b) return b; + if (v instanceof Number n) return BigDecimal.valueOf(n.doubleValue()); + return new BigDecimal(v.toString()); + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/service/AiAnalysisLogService.java b/backend-spring/src/main/java/com/erp/ai/service/AiAnalysisLogService.java new file mode 100644 index 00000000..d988bd62 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/service/AiAnalysisLogService.java @@ -0,0 +1,36 @@ +package com.erp.ai.service; + +import com.erp.ai.mapper.AiAnalysisLogMapper; +import com.erp.ai.model.AiAnalysisLog; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.List; + +/** + * AI 분석 이력 서비스. + * vexplor aiAnalysisLogService.ts 1:1 포팅 (역공학된 인터페이스). + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiAnalysisLogService { + + private final AiAnalysisLogMapper analysisLogMapper; + + public void save(AiAnalysisLog log) { + if (log.getExecution_type() == null) log.setExecution_type("manual"); + analysisLogMapper.insert(log); + } + + public List getRecentLogs(long groupId, int days, int limit) { + return analysisLogMapper.getRecentLogs(groupId, days, limit); + } + + public BigDecimal getAverageAccuracy(long groupId) { + BigDecimal v = analysisLogMapper.getAverageAccuracy(groupId); + return v != null ? v : BigDecimal.ZERO; + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/service/AiKnowledgeService.java b/backend-spring/src/main/java/com/erp/ai/service/AiKnowledgeService.java new file mode 100644 index 00000000..bcac1db8 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/service/AiKnowledgeService.java @@ -0,0 +1,72 @@ +package com.erp.ai.service; + +import com.erp.ai.dto.KnowledgeFileRequest; +import com.erp.ai.mapper.AiKnowledgeFileMapper; +import com.erp.ai.model.AiKnowledgeFile; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * AI 지식 파일 서비스. + * RAG 라이브러리 + 커스텀 업로드. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiKnowledgeService { + + private final AiKnowledgeFileMapper knowledgeMapper; + + public List list(String category, String companyCode) { + Map filters = new HashMap<>(); + if (category != null) filters.put("category", category); + if (companyCode != null) filters.put("company_code", companyCode); + return knowledgeMapper.list(filters); + } + + public AiKnowledgeFile getById(long id) { + return knowledgeMapper.getById(id); + } + + @Transactional + public AiKnowledgeFile create(KnowledgeFileRequest req, String userId, String companyCode) { + AiKnowledgeFile f = new AiKnowledgeFile(); + f.setName(req.getName()); + f.setFile_name(req.getFile_name()); + f.setCategory(req.getCategory()); + f.setDescription(req.getDescription()); + f.setContent(req.getContent()); + f.setFile_size(req.getFile_size() != null ? req.getFile_size() : 0L); + f.setMime_type(req.getMime_type()); + f.setCompany_code(companyCode); + f.setCreated_by(userId); + knowledgeMapper.insert(f); + return knowledgeMapper.getById(f.getId()); + } + + @Transactional + public AiKnowledgeFile update(long id, KnowledgeFileRequest req) { + AiKnowledgeFile f = new AiKnowledgeFile(); + f.setId(id); + f.setName(req.getName()); + f.setFile_name(req.getFile_name()); + f.setCategory(req.getCategory()); + f.setDescription(req.getDescription()); + f.setContent(req.getContent()); + f.setFile_size(req.getFile_size()); + f.setMime_type(req.getMime_type()); + knowledgeMapper.update(f); + return knowledgeMapper.getById(id); + } + + @Transactional + public void delete(long id) { + knowledgeMapper.delete(id); + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/service/AiSchedulerService.java b/backend-spring/src/main/java/com/erp/ai/service/AiSchedulerService.java new file mode 100644 index 00000000..ad107852 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/service/AiSchedulerService.java @@ -0,0 +1,184 @@ +package com.erp.ai.service; + +import com.erp.ai.dto.ScheduleRequest; +import com.erp.ai.exception.AiAgentException; +import com.erp.ai.mapper.AiAgentScheduleMapper; +import com.erp.ai.model.AiAgentSchedule; +import com.erp.ai.scheduler.MultiAgentExecutionJob; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.CronExpression; +import org.quartz.CronScheduleBuilder; +import org.quartz.JobBuilder; +import org.quartz.JobDetail; +import org.quartz.JobKey; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.Trigger; +import org.quartz.TriggerBuilder; +import org.quartz.TriggerKey; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.TimeZone; + +/** + * AI 스케줄 관리 서비스 (Quartz JDBC). + * vexplor aiSchedulerService.ts 1:1 포팅. + * architecture §8. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiSchedulerService { + + private static final String JOB_GROUP = "ai-agent-schedule"; + private static final String TRIGGER_GROUP = "ai-agent-trigger"; + + private final AiAgentScheduleMapper scheduleMapper; + private final Scheduler scheduler; + + @Qualifier("aiObjectMapper") + private final ObjectMapper objectMapper; + + /** + * 서버 시작 시 활성 스케줄 모두 Quartz에 등록. + */ + @PostConstruct + public void initializeSchedules() { + try { + List schedules = scheduleMapper.listActive(); + for (AiAgentSchedule s : schedules) { + try { + registerJob(s); + } catch (Exception e) { + log.warn("스케줄 등록 실패: {} - {}", s.getName(), e.getMessage()); + } + } + log.info("AI 스케줄러 초기화: {}개 활성 스케줄", schedules.size()); + } catch (Exception e) { + log.warn("AI 스케줄러 초기화 실패: {}", e.getMessage()); + } + } + + public List list() { + return scheduleMapper.listAll(); + } + + public AiAgentSchedule getById(long id) { + return scheduleMapper.getById(id); + } + + @Transactional + public AiAgentSchedule create(ScheduleRequest req, String userId) { + if (!CronExpression.isValidExpression(req.getCron_expression())) { + throw new AiAgentException("유효하지 않은 cron 표현식입니다."); + } + AiAgentSchedule s = new AiAgentSchedule(); + s.setName(req.getName()); + s.setGroup_id(req.getGroup_id()); + s.setCron_expression(req.getCron_expression()); + s.setTimezone(req.getTimezone() != null ? req.getTimezone() : "Asia/Seoul"); + s.setInput_message(req.getInput_message()); + s.setNotification(toJson(req.getNotification())); + s.setIs_active(req.getIs_active() != null ? req.getIs_active() : Boolean.TRUE); + s.setCreated_by(userId); + scheduleMapper.insert(s); + AiAgentSchedule saved = scheduleMapper.getById(s.getId()); + + if (Boolean.TRUE.equals(saved.getIs_active())) { + try { + registerJob(saved); + } catch (Exception e) { + log.warn("Quartz 등록 실패: {}", e.getMessage()); + } + } + log.info("AI 스케줄 생성: {} ({})", req.getName(), req.getCron_expression()); + return saved; + } + + @Transactional + public AiAgentSchedule update(long id, ScheduleRequest req) { + AiAgentSchedule existing = scheduleMapper.getById(id); + if (existing == null) return null; + + if (req.getCron_expression() != null + && !CronExpression.isValidExpression(req.getCron_expression())) { + throw new AiAgentException("유효하지 않은 cron 표현식입니다."); + } + + AiAgentSchedule s = new AiAgentSchedule(); + s.setId(id); + s.setName(req.getName()); + s.setCron_expression(req.getCron_expression()); + s.setTimezone(req.getTimezone()); + s.setInput_message(req.getInput_message()); + if (req.getNotification() != null) s.setNotification(toJson(req.getNotification())); + s.setIs_active(req.getIs_active()); + scheduleMapper.update(s); + + AiAgentSchedule saved = scheduleMapper.getById(id); + try { + unregisterJob(id); + if (Boolean.TRUE.equals(saved.getIs_active())) registerJob(saved); + } catch (Exception e) { + log.warn("Quartz 재등록 실패: {}", e.getMessage()); + } + return saved; + } + + @Transactional + public void delete(long id) { + try { + unregisterJob(id); + } catch (Exception e) { + log.warn("Quartz 해제 실패: {}", e.getMessage()); + } + scheduleMapper.delete(id); + } + + private void registerJob(AiAgentSchedule schedule) throws SchedulerException { + if (!Boolean.TRUE.equals(schedule.getIs_active())) return; + JobKey jobKey = new JobKey("schedule-" + schedule.getId(), JOB_GROUP); + TriggerKey triggerKey = new TriggerKey("trigger-" + schedule.getId(), TRIGGER_GROUP); + + JobDetail jobDetail = JobBuilder.newJob(MultiAgentExecutionJob.class) + .withIdentity(jobKey) + .usingJobData("scheduleId", schedule.getId()) + .storeDurably() + .build(); + + TimeZone tz = TimeZone.getTimeZone(schedule.getTimezone() != null + ? schedule.getTimezone() : "Asia/Seoul"); + Trigger trigger = TriggerBuilder.newTrigger() + .withIdentity(triggerKey) + .forJob(jobKey) + .withSchedule(CronScheduleBuilder.cronSchedule(schedule.getCron_expression()) + .inTimeZone(tz)) + .build(); + + if (scheduler.checkExists(jobKey)) scheduler.deleteJob(jobKey); + scheduler.scheduleJob(jobDetail, trigger); + } + + private void unregisterJob(long scheduleId) throws SchedulerException { + JobKey jobKey = new JobKey("schedule-" + scheduleId, JOB_GROUP); + if (scheduler.checkExists(jobKey)) { + scheduler.deleteJob(jobKey); + } + } + + private String toJson(Object value) { + if (value == null) return "{}"; + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + return "{}"; + } + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/service/MultiAgentExecutionEngine.java b/backend-spring/src/main/java/com/erp/ai/service/MultiAgentExecutionEngine.java new file mode 100644 index 00000000..72a3f8a6 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/service/MultiAgentExecutionEngine.java @@ -0,0 +1,439 @@ +package com.erp.ai.service; + +import com.erp.ai.client.LlmClient; +import com.erp.ai.client.LlmClientFactory; +import com.erp.ai.dto.GroupExecutionResult; +import com.erp.ai.exception.AiAgentException; +import com.erp.ai.mapper.AiAgentMapper; +import com.erp.ai.model.AiAgent; +import com.erp.ai.model.AiAgentConversation; +import com.erp.ai.model.AiAgentGroup; +import com.erp.ai.model.AiAgentGroupMember; +import com.erp.ai.model.AiAgentUsageLog; +import com.erp.ai.model.AiAnalysisLog; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; + +/** + * 멀티 에이전트 실행 엔진 (★ 핵심). + * vexplor multiAgentExecutionEngine.ts 1:1 포팅. + * + * - sequential: 1→2→3 순차, 이전 결과를 다음에 전달 + * - parallel: 전체 동시 실행 + * - mixed: execution_order 같으면 병렬, 다르면 순차 + * + * architecture §7. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MultiAgentExecutionEngine { + + private final AiAgentGroupService groupService; + private final AiAgentMapper agentMapper; + private final AiAgentConversationService conversationService; + private final AiAgentUsageService usageService; + private final AiAnalysisLogService analysisLogService; + private final LlmClientFactory llmClientFactory; + + @Qualifier("aiObjectMapper") + private final ObjectMapper objectMapper; + + @Qualifier("aiAgentExecutor") + private final ExecutorService aiAgentExecutor; + + /** + * 그룹 실행 entry point. + */ + public GroupExecutionResult execute(long groupId, String userMessage, String userId, Long apiKeyId) { + AiAgentGroup group = groupService.getEntityById(groupId); + if (group == null) throw new AiAgentException("멀티 에이전트 그룹을 찾을 수 없습니다."); + + List members = groupService.listMembers(groupId); + if (members.isEmpty()) throw new AiAgentException("그룹에 에이전트가 없습니다."); + + String executionMode = group.getExecution_mode() != null ? group.getExecution_mode() : "mixed"; + long startTime = System.currentTimeMillis(); + + log.info("멀티 에이전트 실행 시작: {} ({}) - \"{}\"", group.getName(), executionMode, + userMessage.length() > 50 ? userMessage.substring(0, 50) + "..." : userMessage); + + // 과거 분석 이력 컨텍스트 (try/catch — 이력 없으면 무시) + String historyContext = ""; + try { + List recentLogs = analysisLogService.getRecentLogs(groupId, 30, 5); + if (!recentLogs.isEmpty()) { + StringBuilder sb = new StringBuilder("\n[과거 분석 이력 (최근 5건)]:\n"); + for (AiAnalysisLog log : recentLogs) { + String date = log.getCreated_at() != null + ? log.getCreated_at().format(DateTimeFormatter.ISO_LOCAL_DATE) + : ""; + String preview = log.getAnalysis_result() != null && log.getAnalysis_result().length() > 200 + ? log.getAnalysis_result().substring(0, 200) + "..." + : log.getAnalysis_result(); + sb.append("- ").append(date).append(": ").append(preview).append("\n"); + } + historyContext = sb.toString(); + } + BigDecimal accuracy = analysisLogService.getAverageAccuracy(groupId); + if (accuracy != null && accuracy.compareTo(BigDecimal.ZERO) > 0) { + historyContext += "\n평균 예측 정확도: " + accuracy.setScale(1, java.math.RoundingMode.HALF_UP) + "%"; + } + } catch (Exception ignored) { + // 이력 조회 실패는 무시 + } + + String enrichedMessage = historyContext.isEmpty() ? userMessage : userMessage + "\n\n" + historyContext; + + List stepResults; + switch (executionMode) { + case "parallel" -> stepResults = executeParallel(members, enrichedMessage, ""); + case "sequential" -> stepResults = executeSequential(members, enrichedMessage); + default -> stepResults = executeMixed(members, enrichedMessage); + } + + String finalSummary = buildFinalSummary(stepResults, userMessage); + long totalTokens = stepResults.stream().mapToLong(ExecutionStepResult::tokensUsed).sum(); + long totalDuration = System.currentTimeMillis() - startTime; + + // 사이드 이펙트 — 별도 트랜잭션 (REQUIRES_NEW) + try { + persistResults(group, executionMode, userMessage, stepResults, + finalSummary, totalTokens, totalDuration, userId, apiKeyId); + } catch (Exception e) { + log.warn("멀티 에이전트 결과 적재 실패: {}", e.getMessage()); + } + + log.info("멀티 에이전트 실행 완료: {} - {} tokens, {}ms", group.getName(), totalTokens, totalDuration); + + // GroupExecutionResult 매핑 + List> stepsOut = new ArrayList<>(); + for (ExecutionStepResult r : stepResults) { + Map m = new HashMap<>(); + m.put("order", r.executionOrder()); + m.put("role", r.roleName()); + m.put("agent", r.agentName()); + m.put("model", r.modelName()); + m.put("response", r.response()); + m.put("tokens", r.tokensUsed()); + m.put("duration_ms", r.durationMs()); + m.put("memberId", r.memberId()); + m.put("connectorResults", r.connectorResults()); + stepsOut.add(m); + } + return GroupExecutionResult.builder() + .groupId(group.getId()) + .groupName(group.getName()) + .executionMode(executionMode) + .steps(stepsOut) + .finalSummary(finalSummary) + .totalTokens(totalTokens) + .totalDurationMs(totalDuration) + .build(); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 30) + public void persistResults(AiAgentGroup group, String executionMode, String userMessage, + List stepResults, String finalSummary, + long totalTokens, long totalDuration, String userId, Long apiKeyId) { + // 대화 저장 + try { + AiAgentConversation conv = conversationService.createConversation(null, userId, apiKeyId); + String title = "[" + group.getName() + "] " + + (userMessage.length() > 100 ? userMessage.substring(0, 100) : userMessage); + Map meta = Map.of( + "group_id", group.getId(), + "group_name", group.getName(), + "execution_mode", executionMode); + conversationService.updateMeta(conv.getId(), title, toJson(meta)); + conversationService.addMessage(conv.getId(), "user", userMessage, 0, null); + for (ExecutionStepResult step : stepResults) { + Map stepMeta = Map.of( + "role_name", step.roleName(), + "agent_name", step.agentName(), + "model_name", step.modelName(), + "execution_order", step.executionOrder(), + "duration_ms", step.durationMs()); + conversationService.addMessageWithMetadata(conv.getId(), "assistant", + "[" + step.roleName() + " - " + step.agentName() + "]\n" + step.response(), + (int) step.tokensUsed(), toJson(stepMeta)); + } + } catch (Exception e) { + log.warn("멀티 에이전트 대화 저장 실패: {}", e.getMessage()); + } + + // 분석 이력 저장 + try { + AiAnalysisLog analysis = new AiAnalysisLog(); + analysis.setGroup_id(group.getId()); + analysis.setExecution_type(apiKeyId != null ? "api" : "manual"); + analysis.setInput_message(userMessage); + analysis.setAnalysis_result(finalSummary); + analysis.setTokens_used((int) totalTokens); + analysis.setDuration_ms((int) totalDuration); + analysis.setCompany_code(group.getCompany_code()); + analysisLogService.save(analysis); + } catch (Exception e) { + log.warn("분석 이력 저장 실패: {}", e.getMessage()); + } + + // 사용량 로깅 + try { + AiAgentUsageLog usage = new AiAgentUsageLog(); + usage.setUser_id(userId); + usage.setApi_key_id(apiKeyId); + usage.setTotal_tokens((int) totalTokens); + usage.setResponse_time_ms((int) totalDuration); + usage.setSuccess(true); + usage.setRequest_path("/groups/" + group.getId()); + usage.setCompany_code(group.getCompany_code()); + usageService.log(usage); + } catch (Exception e) { + log.warn("사용량 로그 실패: {}", e.getMessage()); + } + } + + private List executeSequential(List members, String userMessage) { + List sorted = new ArrayList<>(members); + sorted.sort((a, b) -> Integer.compare(a.getExecution_order(), b.getExecution_order())); + List results = new ArrayList<>(); + StringBuilder previousContext = new StringBuilder(); + for (AiAgentGroupMember m : sorted) { + ExecutionStepResult r = executeSingleAgent(m, userMessage, previousContext.toString()); + results.add(r); + previousContext.append("\n[").append(m.getRole_name()).append(" 결과]:\n") + .append(r.response()).append("\n"); + } + return results; + } + + private List executeParallel(List members, + String userMessage, String previousContext) { + List> futures = new ArrayList<>(); + for (AiAgentGroupMember m : members) { + futures.add(CompletableFuture.supplyAsync( + () -> executeSingleAgent(m, userMessage, previousContext), aiAgentExecutor)); + } + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + List out = new ArrayList<>(); + for (CompletableFuture f : futures) { + out.add(f.join()); + } + return out; + } + + private List executeMixed(List members, String userMessage) { + TreeMap> orderGroups = new TreeMap<>(); + for (AiAgentGroupMember m : members) { + orderGroups.computeIfAbsent(m.getExecution_order(), k -> new ArrayList<>()).add(m); + } + List all = new ArrayList<>(); + StringBuilder previousContext = new StringBuilder(); + for (Map.Entry> entry : orderGroups.entrySet()) { + List stage = entry.getValue(); + List stageResults; + if (stage.size() == 1) { + stageResults = List.of(executeSingleAgent(stage.get(0), userMessage, previousContext.toString())); + } else { + stageResults = executeParallel(stage, userMessage, previousContext.toString()); + } + all.addAll(stageResults); + for (ExecutionStepResult r : stageResults) { + previousContext.append("\n[").append(r.roleName()).append(" 결과]:\n") + .append(r.response()).append("\n"); + } + } + return all; + } + + @SuppressWarnings("unchecked") + private ExecutionStepResult executeSingleAgent(AiAgentGroupMember member, + String userMessage, String previousContext) { + long startTime = System.currentTimeMillis(); + AiAgent agent = agentMapper.getById(member.getAgent_id()); + if (agent == null) { + return new ExecutionStepResult(member.getId(), member.getRole_name(), + "알 수 없음", "unknown", member.getExecution_order(), + "에이전트를 찾을 수 없습니다.", 0L, System.currentTimeMillis() - startTime, List.of()); + } + + // 커넥터 데이터 수집 (invyone에는 외부 커넥터 테이블 부재 — info 만 반환) + List> connectorResults = new ArrayList<>(); + StringBuilder connectorContext = new StringBuilder(); + List> connectors = parseConnectors(member.getConnectors()); + for (Map c : connectors) { + Map data = executeConnectorPlaceholder(c); + Map entry = new HashMap<>(); + entry.put("connector", c.get("name")); + entry.put("type", c.get("type")); + entry.put("data", data); + connectorResults.add(entry); + connectorContext.append("\n[데이터 소스: ").append(c.get("name")) + .append(" (").append(c.get("type")).append(")]:\n") + .append(safeJson(data)).append("\n"); + } + + // 지식 파일 컨텍스트 (config.knowledge_files) + String knowledgeContext = buildKnowledgeContext(agent); + + String systemPrompt = (agent.getSystem_prompt() != null + ? agent.getSystem_prompt() : "당신은 도움이 되는 AI 어시스턴트입니다.") + + "\n당신의 역할: " + member.getRole_name() + + knowledgeContext + + (connectorContext.length() > 0 ? "\n사용 가능한 데이터:\n" + connectorContext : "") + + (previousContext != null && !previousContext.isEmpty() + ? "\n이전 에이전트들의 분석 결과:\n" + previousContext : ""); + + // LLM 호출 + try { + Map agentConfig = parseConfig(agent.getConfig()); + Map llmRequest = new HashMap<>(); + llmRequest.put("model", agent.getModel()); + llmRequest.put("messages", List.of( + Map.of("role", "system", "content", systemPrompt), + Map.of("role", "user", "content", userMessage) + )); + llmRequest.put("max_tokens", agentConfig.getOrDefault("max_tokens", 2000)); + llmRequest.put("temperature", agentConfig.getOrDefault("temperature", 0.7)); + + LlmClient client = llmClientFactory.pick(agent.getModel()); + Map result = client.chat(llmRequest); + + String text = "응답 없음"; + long tokens = 0L; + List> choices = (List>) result.getOrDefault("choices", List.of()); + if (!choices.isEmpty()) { + Map message = (Map) choices.get(0).get("message"); + if (message != null) { + Object content = message.get("content"); + if (content != null) text = String.valueOf(content); + } + } + Map usage = (Map) result.get("usage"); + if (usage != null && usage.get("total_tokens") instanceof Number n) { + tokens = n.longValue(); + } + + return new ExecutionStepResult(member.getId(), member.getRole_name(), + agent.getName(), agent.getModel(), member.getExecution_order(), + text, tokens, System.currentTimeMillis() - startTime, connectorResults); + } catch (Exception e) { + log.warn("에이전트 실행 실패 ({}): {}", member.getRole_name(), e.getMessage()); + return new ExecutionStepResult(member.getId(), member.getRole_name(), + agent.getName(), agent.getModel(), member.getExecution_order(), + "[실행 실패] " + e.getMessage(), 0L, + System.currentTimeMillis() - startTime, connectorResults); + } + } + + @SuppressWarnings("unchecked") + private String buildKnowledgeContext(AiAgent agent) { + try { + Map config = parseConfig(agent.getConfig()); + Object kfRaw = config.get("knowledge_files"); + if (!(kfRaw instanceof List rawList) || rawList.isEmpty()) return ""; + + StringBuilder out = new StringBuilder("\n[참고 지식 문서]:\n"); + for (Object item : rawList) { + if (!(item instanceof Map rawMap)) continue; + Map mapItem = (Map) rawMap; + Object nameObj = mapItem.get("name"); + String name = nameObj != null ? String.valueOf(nameObj) : ""; + Object content = mapItem.get("content"); + if (content != null) { + String c = String.valueOf(content); + out.append("--- ").append(name).append(" ---\n") + .append(c.length() > 10000 ? c.substring(0, 10000) : c).append("\n\n"); + } + // library_id resolution은 KnowledgeService 의존성 회피 위해 생략 (향후 통합) + } + return out.toString(); + } catch (Exception e) { + return ""; + } + } + + private Map executeConnectorPlaceholder(Map connector) { + String type = String.valueOf(connector.getOrDefault("type", "")); + String name = String.valueOf(connector.getOrDefault("name", "")); + return Map.of("type", type, "name", name, "info", "커넥터 연결 준비됨"); + } + + private List> parseConnectors(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + return List.of(); + } + } + + private Map parseConfig(String json) { + if (json == null || json.isBlank()) return new HashMap<>(); + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + return new HashMap<>(); + } + } + + private String safeJson(Object value) { + try { + String s = objectMapper.writeValueAsString(value); + return s.length() > 2000 ? s.substring(0, 2000) : s; + } catch (JsonProcessingException e) { + return String.valueOf(value); + } + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + return "{}"; + } + } + + private String buildFinalSummary(List results, String originalQuestion) { + StringBuilder sb = new StringBuilder("질문: ").append(originalQuestion).append("\n\n"); + for (int i = 0; i < results.size(); i++) { + ExecutionStepResult r = results.get(i); + sb.append("[").append(r.roleName()).append(" (").append(r.agentName()).append(")]:\n") + .append(r.response()); + if (i < results.size() - 1) sb.append("\n\n---\n\n"); + } + return sb.toString(); + } + + /** + * 단일 에이전트 실행 결과. + */ + public record ExecutionStepResult( + long memberId, + String roleName, + String agentName, + String modelName, + int executionOrder, + String response, + long tokensUsed, + long durationMs, + List> connectorResults + ) {} +} diff --git a/backend-spring/src/main/java/com/erp/ai/service/OpenClawSyncService.java b/backend-spring/src/main/java/com/erp/ai/service/OpenClawSyncService.java new file mode 100644 index 00000000..803b50f0 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/service/OpenClawSyncService.java @@ -0,0 +1,129 @@ +package com.erp.ai.service; + +import com.erp.ai.client.OpenClawClient; +import com.erp.ai.config.OpenClawProperties; +import com.erp.ai.exception.OpenClawException; +import com.erp.ai.mapper.AiAgentMapper; +import com.erp.ai.mapper.AiLlmProviderMapper; +import com.erp.ai.model.AiAgent; +import com.erp.ai.model.AiLlmProvider; +import com.erp.ai.util.AesGcmCipher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Pipeline DB → OpenClaw config 동기화. + * vexplor openClawSyncService.ts 1:1 포팅 (HTTP API 방식). + * OpenClaw 가 비활성(enabled=false) 이면 모든 메서드는 no-op. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OpenClawSyncService { + + private final AiLlmProviderMapper providerMapper; + private final AiAgentMapper agentMapper; + private final AesGcmCipher cipher; + private final OpenClawClient openClawClient; + private final OpenClawProperties openClawProperties; + + /** + * LLM 프로바이더를 OpenClaw auth profiles 로 동기화. + */ + public void syncProviders() { + if (!openClawProperties.isEnabled()) { + log.debug("OpenClaw 비활성 — providers sync skip"); + return; + } + try { + List providers = providerMapper.listActive(); + Map authProfiles = new HashMap<>(); + for (AiLlmProvider p : providers) { + String decryptedKey = cipher.decrypt(p.getApi_key_encrypted()); + String profileKey = "pipeline-" + p.getName() + "-" + p.getId(); + Map profile = buildAuthProfile(p, decryptedKey); + if (profile != null) authProfiles.put(profileKey, profile); + } + + Map body = new HashMap<>(); + body.put("authProfiles", authProfiles); + if (!providers.isEmpty()) { + AiLlmProvider primary = providers.get(0); + Map models = new HashMap<>(); + models.put("default", primary.getName() + ":" + primary.getModel_name()); + models.put("authProfile", "pipeline-" + primary.getName() + "-" + primary.getId()); + body.put("models", models); + } + + openClawClient.syncAuthProfiles(body); + log.info("OpenClaw 프로바이더 동기화: {}개", providers.size()); + } catch (OpenClawException e) { + log.warn("OpenClaw 프로바이더 동기화 실패 (graceful): {}", e.getMessage()); + } catch (Exception e) { + log.error("OpenClaw 프로바이더 동기화 오류", e); + } + } + + /** + * 에이전트를 OpenClaw agents 로 동기화. + */ + public void syncAgents() { + if (!openClawProperties.isEnabled()) { + log.debug("OpenClaw 비활성 — agents sync skip"); + return; + } + try { + List agents = agentMapper.getActiveAgents(); + Map clawAgents = new HashMap<>(); + for (AiAgent agent : agents) { + Map entry = new HashMap<>(); + entry.put("displayName", agent.getName()); + entry.put("description", agent.getDescription() != null ? agent.getDescription() : ""); + entry.put("model", agent.getModel()); + entry.put("systemPrompt", agent.getSystem_prompt() != null ? agent.getSystem_prompt() : ""); + entry.put("tools", agent.getTools()); + entry.put("config", agent.getConfig()); + clawAgents.put(agent.getAgent_id(), entry); + } + openClawClient.syncAgents(Map.of("agents", clawAgents)); + log.info("OpenClaw 에이전트 동기화: {}개", agents.size()); + } catch (OpenClawException e) { + log.warn("OpenClaw 에이전트 동기화 실패 (graceful): {}", e.getMessage()); + } catch (Exception e) { + log.error("OpenClaw 에이전트 동기화 오류", e); + } + } + + public void syncAll() { + syncProviders(); + syncAgents(); + } + + private Map buildAuthProfile(AiLlmProvider p, String decryptedKey) { + Map profile = new HashMap<>(); + switch (p.getName()) { + case "anthropic", "openai", "google" -> { + profile.put("provider", p.getName()); + profile.put("apiKey", decryptedKey); + } + case "deepseek" -> { + profile.put("provider", "openai-compat"); + profile.put("apiKey", decryptedKey); + profile.put("baseUrl", p.getEndpoint() != null ? p.getEndpoint() : "https://api.deepseek.com/v1"); + } + case "ollama" -> { + profile.put("provider", "ollama"); + profile.put("baseUrl", p.getEndpoint() != null ? p.getEndpoint() : "http://localhost:11434"); + } + default -> { + return null; + } + } + return profile; + } +} diff --git a/backend-spring/src/main/java/com/erp/ai/util/AesGcmCipher.java b/backend-spring/src/main/java/com/erp/ai/util/AesGcmCipher.java new file mode 100644 index 00000000..d3158ce4 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/ai/util/AesGcmCipher.java @@ -0,0 +1,93 @@ +package com.erp.ai.util; + +import com.erp.ai.exception.AiAgentException; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * LLM API 키 AES-256-GCM 암복호화 유틸. + * architecture §5.3 — base64( IV(12B) || CIPHERTEXT || TAG(16B) ) + * + * 키는 application.yml ai.encryption-key (Base64 32B)에서 주입. + */ +@Slf4j +@Component +public class AesGcmCipher { + + private static final int IV_LENGTH = 12; + private static final int TAG_LENGTH_BITS = 128; + + private final String encodedKey; + private SecretKey secretKey; + private final SecureRandom secureRandom = new SecureRandom(); + + public AesGcmCipher(@Value("${ai.encryption-key}") String encodedKey) { + this.encodedKey = encodedKey; + } + + @PostConstruct + public void init() { + try { + byte[] keyBytes = Base64.getDecoder().decode(encodedKey); + if (keyBytes.length != 16 && keyBytes.length != 24 && keyBytes.length != 32) { + // 잘못된 길이인 경우 SHA-256 으로 32 byte derived 키 사용 + java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); + keyBytes = md.digest(encodedKey.getBytes(StandardCharsets.UTF_8)); + } + this.secretKey = new SecretKeySpec(keyBytes, "AES"); + } catch (Exception e) { + log.error("AES-GCM 키 초기화 실패: {}", e.getMessage()); + throw new AiAgentException("암호화 키 초기화 실패", e); + } + } + + public String encrypt(String plain) { + if (plain == null) return null; + try { + byte[] iv = new byte[IV_LENGTH]; + secureRandom.nextBytes(iv); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + byte[] cipherText = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); + + ByteBuffer buf = ByteBuffer.allocate(IV_LENGTH + cipherText.length); + buf.put(iv).put(cipherText); + return Base64.getEncoder().encodeToString(buf.array()); + } catch (Exception e) { + throw new AiAgentException("암호화 실패: " + e.getMessage(), e); + } + } + + public String decrypt(String cipherTextB64) { + if (cipherTextB64 == null) return null; + try { + byte[] all = Base64.getDecoder().decode(cipherTextB64); + if (all.length < IV_LENGTH + 16) { + throw new AiAgentException("암호문 길이가 잘못되었습니다"); + } + byte[] iv = new byte[IV_LENGTH]; + byte[] ct = new byte[all.length - IV_LENGTH]; + System.arraycopy(all, 0, iv, 0, IV_LENGTH); + System.arraycopy(all, IV_LENGTH, ct, 0, ct.length); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + byte[] plain = cipher.doFinal(ct); + return new String(plain, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new AiAgentException("복호화 실패: " + e.getMessage(), e); + } + } +} diff --git a/backend-spring/src/main/java/com/erp/controller/AiAssistantProxyController.java b/backend-spring/src/main/java/com/erp/controller/AiAssistantProxyController.java deleted file mode 100644 index c63e8ff5..00000000 --- a/backend-spring/src/main/java/com/erp/controller/AiAssistantProxyController.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.erp.controller; - -import com.erp.dto.ApiResponse; -import com.erp.service.AiAssistantProxyService; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.*; -import org.springframework.web.bind.annotation.*; - -import java.util.HashMap; -import java.util.Map; - -@RestController -@RequiredArgsConstructor -@Slf4j -public class AiAssistantProxyController { - - private final AiAssistantProxyService aiAssistantProxyService; - - @GetMapping("/api/ai/v1/status") - public ResponseEntity>> getStatus() { - Map result = new HashMap<>(); - result.put("service_url", "http://127.0.0.1:3100"); - result.put("status", "ok"); - result.put("message", "AI 어시스턴트 프록시 서비스가 구성되었습니다."); - return ResponseEntity.ok(ApiResponse.success(result)); - } - - @RequestMapping( - value = "/api/ai/v1/**", - method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, - RequestMethod.DELETE, RequestMethod.PATCH} - ) - public ResponseEntity proxy( - HttpServletRequest request, - HttpHeaders headers, - @RequestBody(required = false) byte[] body) { - String path = request.getRequestURI().replaceFirst("^/api/ai/v1", ""); - String query = request.getQueryString(); - HttpMethod method = HttpMethod.valueOf(request.getMethod()); - return aiAssistantProxyService.forward(path, query, method, headers, body); - } -} diff --git a/backend-spring/src/main/java/com/erp/security/SecurityConfig.java b/backend-spring/src/main/java/com/erp/security/SecurityConfig.java index a0b4ebcc..35ea14ee 100644 --- a/backend-spring/src/main/java/com/erp/security/SecurityConfig.java +++ b/backend-spring/src/main/java/com/erp/security/SecurityConfig.java @@ -1,5 +1,7 @@ package com.erp.security; +import com.erp.ai.security.AiApiKeyAuthFilter; +import com.erp.ai.service.AiAgentApiKeyService; import com.erp.tenant.CompanyResolver; import com.erp.tenant.SubdomainResolverFilter; import com.erp.tenant.TenantDbSettings; @@ -34,6 +36,7 @@ public class SecurityConfig { private final CompanyResolver companyResolver; private final TenantRoutingDataSource tenantRoutingDataSource; private final TenantDbSettings tenantDbSettings; + private final AiAgentApiKeyService aiAgentApiKeyService; /** * CORS 화이트리스트. 콤마 구분 문자열로 주입 (예: "http://localhost:3000,https://v1.invion.com"). @@ -56,6 +59,8 @@ public class SecurityConfig { .requestMatchers("/api/**").permitAll() .anyRequest().authenticated() ) + .addFilterBefore(new AiApiKeyAuthFilter(aiAgentApiKeyService), + JwtAuthenticationFilter.class) .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) // JwtAuthenticationFilter 뒤 — JWT.company_code 와 서브도메인의 company_code 대조. diff --git a/backend-spring/src/main/java/com/erp/service/AiAssistantProxyService.java b/backend-spring/src/main/java/com/erp/service/AiAssistantProxyService.java deleted file mode 100644 index ed9308a6..00000000 --- a/backend-spring/src/main/java/com/erp/service/AiAssistantProxyService.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.erp.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.*; -import org.springframework.stereotype.Service; -import org.springframework.web.client.ResourceAccessException; -import org.springframework.web.client.RestTemplate; - -import java.net.URI; -import java.util.*; - -@Service -@Slf4j -public class AiAssistantProxyService { - - private static final String AI_SERVICE_BASE = - System.getenv().getOrDefault("AI_ASSISTANT_SERVICE_URL", "http://127.0.0.1:3100"); - - private final ObjectMapper objectMapper = new ObjectMapper(); - - public ResponseEntity forward(String path, String query, - HttpMethod method, HttpHeaders incomingHeaders, - byte[] body) { - String url = AI_SERVICE_BASE + "/api/v1" + path; - if (query != null && !query.isEmpty()) { - url += "?" + query; - } - try { - RestTemplate restTemplate = new RestTemplate(); - HttpHeaders headers = new HttpHeaders(); - MediaType contentType = incomingHeaders.getContentType(); - if (contentType != null) headers.setContentType(contentType); - String auth = incomingHeaders.getFirst(HttpHeaders.AUTHORIZATION); - if (auth != null) headers.set(HttpHeaders.AUTHORIZATION, auth); - String accept = incomingHeaders.getFirst(HttpHeaders.ACCEPT); - if (accept != null) headers.set(HttpHeaders.ACCEPT, accept); - - RequestEntity requestEntity = new RequestEntity<>(body, headers, method, URI.create(url)); - return restTemplate.exchange(requestEntity, byte[].class); - } catch (ResourceAccessException e) { - log.warn("AI service unavailable at {}: {}", url, e.getMessage()); - return buildServiceUnavailableResponse(); - } catch (Exception e) { - log.error("AI proxy error: {}", e.getMessage()); - return buildServiceUnavailableResponse(); - } - } - - private ResponseEntity buildServiceUnavailableResponse() { - try { - Map error = new HashMap<>(); - error.put("success", false); - Map errorDetail = new HashMap<>(); - errorDetail.put("code", "AI_SERVICE_UNAVAILABLE"); - errorDetail.put("message", "AI 어시스턴트 서비스를 사용할 수 없습니다. AI 서비스(기본 3100 포트)를 기동한 뒤 다시 시도하세요."); - error.put("error", errorDetail); - byte[] body = objectMapper.writeValueAsBytes(error); - return ResponseEntity.status(HttpStatus.BAD_GATEWAY) - .contentType(MediaType.APPLICATION_JSON) - .body(body); - } catch (Exception ex) { - return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build(); - } - } -} diff --git a/backend-spring/src/main/resources/application.yml b/backend-spring/src/main/resources/application.yml index 2ea122e3..3ada2443 100644 --- a/backend-spring/src/main/resources/application.yml +++ b/backend-spring/src/main/resources/application.yml @@ -24,14 +24,28 @@ spring: connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 + flyway: + enabled: true + baseline-on-migrate: true + baseline-version: 0 + locations: classpath:db/migration + schemas: public + table: flyway_schema_history mybatis: - mapper-locations: classpath:mapper/*.xml + mapper-locations: classpath:mapper/*.xml,classpath:mapper/ai/*.xml configuration: map-underscore-to-camel-case: false default-fetch-size: 100 default-statement-timeout: 30 +ai: + encryption-key: ${AI_PROVIDER_ENCRYPTION_KEY:dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcy1iYXNlNjQh} + default-timezone: ${AI_DEFAULT_TIMEZONE:Asia/Seoul} + rate-limit-per-minute: ${AI_RATE_LIMIT_PER_MINUTE:60} + llm-http-timeout-sec: ${LLM_HTTP_TIMEOUT_SEC:60} + quartz-clustered: ${AI_QUARTZ_CLUSTERED:false} + jwt: # JWT_SECRET 환경변수 필수. 디폴트 없음 — 미지정 시 앱 기동 실패 (의도된 동작) # 새 secret 생성: openssl rand -base64 64 | tr -d '\n=' @@ -48,6 +62,12 @@ cors: file: upload-dir: ./uploads +openclaw: + enabled: ${OPENCLAW_ENABLED:false} + gateway-url: ${OPENCLAW_GATEWAY_URL:http://localhost:18789} + timeout: 60s + health-check-interval: 30s + logging: level: com.erp: DEBUG diff --git a/backend-spring/src/main/resources/mapper/ai/AiAgentApiKeyMapper.xml b/backend-spring/src/main/resources/mapper/ai/AiAgentApiKeyMapper.xml new file mode 100644 index 00000000..19d4d172 --- /dev/null +++ b/backend-spring/src/main/resources/mapper/ai/AiAgentApiKeyMapper.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + id, name, key_hash, key_prefix, user_id, company_code, agent_id, + permissions::text AS permissions, rate_limit, monthly_token_limit, + status, last_used_at, usage_count, total_tokens, expires_at, created_at + + + + + + + + + + + + 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 (#{name}, #{key_hash}, #{key_prefix}, #{user_id}, #{company_code}, #{agent_id}, + COALESCE(#{permissions}::jsonb, '["chat"]'::jsonb), + COALESCE(#{rate_limit}, 60), + COALESCE(#{monthly_token_limit}, 1000000), + #{expires_at}) + + + + DELETE FROM ai_agent_api_keys + WHERE id = #{id} AND (user_id = #{userId} OR #{userId} = 'wace') + + + + UPDATE ai_agent_api_keys + SET last_used_at = NOW(), usage_count = usage_count + 1 + WHERE id = #{id} + + + + UPDATE ai_agent_api_keys + SET total_tokens = total_tokens + #{tokens} + WHERE id = #{id} + + + + + diff --git a/backend-spring/src/main/resources/mapper/ai/AiAgentConversationMapper.xml b/backend-spring/src/main/resources/mapper/ai/AiAgentConversationMapper.xml new file mode 100644 index 00000000..a9458548 --- /dev/null +++ b/backend-spring/src/main/resources/mapper/ai/AiAgentConversationMapper.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO ai_agent_conversations (conversation_id, agent_id, user_id, api_key_id, title, metadata) + VALUES (#{conversation_id}, #{agent_id}, #{user_id}, #{api_key_id}, #{title}, + COALESCE(#{metadata}::jsonb, '{}'::jsonb)) + + + + UPDATE ai_agent_conversations + SET title = #{title}, metadata = #{metadata}::jsonb, updated_at = NOW() + WHERE id = #{id} + + + + UPDATE ai_agent_conversations + SET message_count = message_count + 1, total_tokens = total_tokens + #{tokens}, updated_at = NOW() + WHERE id = #{id} + + + + DELETE FROM ai_agent_conversations WHERE id = #{id} + + + diff --git a/backend-spring/src/main/resources/mapper/ai/AiAgentGroupMapper.xml b/backend-spring/src/main/resources/mapper/ai/AiAgentGroupMapper.xml new file mode 100644 index 00000000..c302ce56 --- /dev/null +++ b/backend-spring/src/main/resources/mapper/ai/AiAgentGroupMapper.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO ai_agent_groups (group_id, name, description, execution_mode, status, company_code, created_by) + VALUES (#{group_id}, #{name}, #{description}, + COALESCE(#{execution_mode}, 'mixed'), + COALESCE(#{status}, 'active'), + #{company_code}, #{created_by}) + + + + UPDATE ai_agent_groups + + name = #{name}, + description = #{description}, + execution_mode = #{execution_mode}, + status = #{status}, + updated_at = NOW() + + WHERE id = #{id} + + + + UPDATE ai_agent_groups SET status = 'archived', updated_at = NOW() WHERE id = #{id} + + + + UPDATE ai_agent_groups SET updated_at = NOW() WHERE id = #{id} + + + diff --git a/backend-spring/src/main/resources/mapper/ai/AiAgentGroupMemberMapper.xml b/backend-spring/src/main/resources/mapper/ai/AiAgentGroupMemberMapper.xml new file mode 100644 index 00000000..7757515b --- /dev/null +++ b/backend-spring/src/main/resources/mapper/ai/AiAgentGroupMemberMapper.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO ai_agent_group_members (group_id, agent_id, role_name, connectors, execution_order, config) + VALUES (#{group_id}, #{agent_id}, #{role_name}, + COALESCE(#{connectors}::jsonb, '[]'::jsonb), + COALESCE(#{execution_order}, 1), + COALESCE(#{config}::jsonb, '{}'::jsonb)) + + + + UPDATE ai_agent_group_members + + role_name = #{role_name}, + connectors = #{connectors}::jsonb, + execution_order = #{execution_order}, + config = #{config}::jsonb, + updated_at = NOW() + + WHERE id = #{id} + + + + DELETE FROM ai_agent_group_members WHERE id = #{id} + + + + + diff --git a/backend-spring/src/main/resources/mapper/ai/AiAgentMapper.xml b/backend-spring/src/main/resources/mapper/ai/AiAgentMapper.xml new file mode 100644 index 00000000..1a3f55fb --- /dev/null +++ b/backend-spring/src/main/resources/mapper/ai/AiAgentMapper.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO ai_agents (agent_id, name, description, model, system_prompt, tools, config, status, company_code, created_by) + VALUES (#{agent_id}, #{name}, #{description}, + COALESCE(#{model}, 'claude-sonnet-4-20250514'), + #{system_prompt}, + COALESCE(#{tools}::jsonb, '[]'::jsonb), + COALESCE(#{config}::jsonb, '{}'::jsonb), + COALESCE(#{status}, 'active'), + #{company_code}, #{created_by}) + + + + UPDATE ai_agents + + name = #{fields.name}, + description = #{fields.description}, + model = #{fields.model}, + system_prompt = #{fields.system_prompt}, + tools = #{fields.tools}::jsonb, + config = #{fields.config}::jsonb, + status = #{fields.status}, + updated_at = NOW() + + WHERE id = #{id} + + + + UPDATE ai_agents SET status = 'archived', updated_at = NOW() WHERE id = #{id} + + + + + + + diff --git a/backend-spring/src/main/resources/mapper/ai/AiAgentMessageMapper.xml b/backend-spring/src/main/resources/mapper/ai/AiAgentMessageMapper.xml new file mode 100644 index 00000000..64e6d6c1 --- /dev/null +++ b/backend-spring/src/main/resources/mapper/ai/AiAgentMessageMapper.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + INSERT INTO ai_agent_messages (conversation_id, role, content, tool_calls, token_count, metadata) + VALUES (#{conversation_id}, #{role}, #{content}, + + #{tool_calls}::jsonb + NULL + , + COALESCE(#{token_count}, 0), + COALESCE(#{metadata}::jsonb, '{}'::jsonb)) + + + + DELETE FROM ai_agent_messages WHERE conversation_id = #{conversationId} + + + diff --git a/backend-spring/src/main/resources/mapper/ai/AiAgentScheduleMapper.xml b/backend-spring/src/main/resources/mapper/ai/AiAgentScheduleMapper.xml new file mode 100644 index 00000000..8872e5ac --- /dev/null +++ b/backend-spring/src/main/resources/mapper/ai/AiAgentScheduleMapper.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + s.id, s.name, s.group_id, s.cron_expression, s.timezone, s.input_message, + s.notification::text AS notification, s.is_active, s.last_run_at, s.run_count, + s.company_code, s.created_by, s.created_at, s.updated_at, + g.name AS group_name + + + + + + + + + + INSERT INTO ai_agent_schedules + (name, group_id, cron_expression, timezone, input_message, notification, is_active, company_code, created_by) + VALUES (#{name}, #{group_id}, #{cron_expression}, + COALESCE(#{timezone}, 'Asia/Seoul'), + #{input_message}, + COALESCE(#{notification}::jsonb, '{}'::jsonb), + COALESCE(#{is_active}, TRUE), + #{company_code}, #{created_by}) + + + + UPDATE ai_agent_schedules + + name = #{name}, + cron_expression = #{cron_expression}, + timezone = #{timezone}, + input_message = #{input_message}, + notification = #{notification}::jsonb, + is_active = #{is_active}, + updated_at = NOW() + + WHERE id = #{id} + + + + DELETE FROM ai_agent_schedules WHERE id = #{id} + + + + UPDATE ai_agent_schedules + SET last_run_at = NOW(), run_count = run_count + 1 + WHERE id = #{id} + + + diff --git a/backend-spring/src/main/resources/mapper/ai/AiAgentUsageLogMapper.xml b/backend-spring/src/main/resources/mapper/ai/AiAgentUsageLogMapper.xml new file mode 100644 index 00000000..f5e2c31a --- /dev/null +++ b/backend-spring/src/main/resources/mapper/ai/AiAgentUsageLogMapper.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + 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, company_code) + VALUES (#{user_id}, #{api_key_id}, #{agent_id}, #{conversation_id}, + #{provider_name}, #{model_name}, + COALESCE(#{prompt_tokens}, 0), + COALESCE(#{completion_tokens}, 0), + COALESCE(#{total_tokens}, 0), + COALESCE(#{cost_usd}, 0), + #{response_time_ms}, + COALESCE(#{success}, TRUE), + #{error_message}, #{request_path}, + + #{ip_address}::inet + NULL + , + #{company_code}) + + + + + + + + + + + + + + + diff --git a/backend-spring/src/main/resources/mapper/ai/AiAnalysisLogMapper.xml b/backend-spring/src/main/resources/mapper/ai/AiAnalysisLogMapper.xml new file mode 100644 index 00000000..1912801a --- /dev/null +++ b/backend-spring/src/main/resources/mapper/ai/AiAnalysisLogMapper.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO ai_analysis_logs + (group_id, agent_id, schedule_id, execution_type, input_message, analysis_result, + prediction, actual_result, accuracy_score, tokens_used, duration_ms, metadata, company_code) + VALUES (#{group_id}, #{agent_id}, #{schedule_id}, #{execution_type}, + #{input_message}, #{analysis_result}, + + #{prediction}::jsonb + NULL + , + + #{actual_result}::jsonb + NULL + , + #{accuracy_score}, + COALESCE(#{tokens_used}, 0), + #{duration_ms}, + COALESCE(#{metadata}::jsonb, '{}'::jsonb), + #{company_code}) + + + + + + + diff --git a/backend-spring/src/main/resources/mapper/ai/AiKnowledgeFileMapper.xml b/backend-spring/src/main/resources/mapper/ai/AiKnowledgeFileMapper.xml new file mode 100644 index 00000000..09e3df4e --- /dev/null +++ b/backend-spring/src/main/resources/mapper/ai/AiKnowledgeFileMapper.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO ai_knowledge_files + (name, file_name, category, description, content, file_size, mime_type, company_code, created_by) + VALUES (#{name}, #{file_name}, #{category}, #{description}, #{content}, + COALESCE(#{file_size}, 0), + #{mime_type}, #{company_code}, #{created_by}) + + + + UPDATE ai_knowledge_files + + name = #{name}, + file_name = #{file_name}, + category = #{category}, + description = #{description}, + content = #{content}, + file_size = #{file_size}, + mime_type = #{mime_type}, + updated_at = NOW() + + WHERE id = #{id} + + + + DELETE FROM ai_knowledge_files WHERE id = #{id} + + + diff --git a/backend-spring/src/main/resources/mapper/ai/AiLlmProviderMapper.xml b/backend-spring/src/main/resources/mapper/ai/AiLlmProviderMapper.xml new file mode 100644 index 00000000..7577919c --- /dev/null +++ b/backend-spring/src/main/resources/mapper/ai/AiLlmProviderMapper.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + id, name, display_name, api_key_encrypted, model_name, endpoint, priority, + max_tokens, temperature, cost_per_1k_input, cost_per_1k_output, is_active, + config::text AS config, created_at, updated_at + + + + + + + + + + + + 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, is_active, config) + VALUES (#{name}, #{display_name}, #{api_key_encrypted}, #{model_name}, #{endpoint}, + COALESCE(#{priority}, 1), + COALESCE(#{max_tokens}, 4096), + COALESCE(#{temperature}, 0.70), + COALESCE(#{cost_per_1k_input}, 0), + COALESCE(#{cost_per_1k_output}, 0), + COALESCE(#{is_active}, TRUE), + COALESCE(#{config}::jsonb, '{}'::jsonb)) + + + + UPDATE ai_llm_providers + + display_name = #{display_name}, + api_key_encrypted = #{api_key_encrypted}, + model_name = #{model_name}, + endpoint = #{endpoint}, + priority = #{priority}, + max_tokens = #{max_tokens}, + temperature = #{temperature}, + cost_per_1k_input = #{cost_per_1k_input}, + cost_per_1k_output = #{cost_per_1k_output}, + is_active = #{is_active}, + config = #{config}::jsonb, + updated_at = NOW() + + WHERE id = #{id} + + + + DELETE FROM ai_llm_providers WHERE id = #{id} + + + diff --git a/backend-spring/src/main/resources/mapper/aiAssistantProxy.xml b/backend-spring/src/main/resources/mapper/aiAssistantProxy.xml deleted file mode 100644 index 83d7e38d..00000000 --- a/backend-spring/src/main/resources/mapper/aiAssistantProxy.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/docker-compose.backend.win.yml b/docker-compose.backend.win.yml index 895380b7..936a01a3 100644 --- a/docker-compose.backend.win.yml +++ b/docker-compose.backend.win.yml @@ -18,6 +18,9 @@ services: - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - JWT_EXPIRATION=86400000 - FILE_UPLOAD_DIR=./uploads + # OpenClaw 연동 설정 (OPENCLAW_ENABLED=false 시 클라이언트 비활성) + - OPENCLAW_ENABLED=true + - OPENCLAW_GATEWAY_URL=http://openclaw:18789 volumes: - ./backend-spring:/app networks: @@ -29,6 +32,37 @@ services: timeout: 10s retries: 3 start_period: 90s + depends_on: + openclaw: + condition: service_healthy + + # OpenClaw 외부 AI 엔진 (port 18789) + # TODO: OPENCLAW_IMAGE 환경변수에 실제 이미지 경로 지정 필요. + # - 오픈소스 Docker Hub 이미지인 경우: docker.io//openclaw:latest + # - 사내 빌드 이미지인 경우: //openclaw: + # 확인 전까지 OPENCLAW_IMAGE 환경변수를 .env 파일 또는 실행 커맨드에 직접 지정하세요. + openclaw: + image: ${OPENCLAW_IMAGE:-openclaw/openclaw:latest} + container_name: invyone-openclaw + ports: + - "18789:18789" + environment: + - OPENCLAW_PORT=18789 + - OPENCLAW_LOG_LEVEL=info + volumes: + - openclaw-config:/root/.openclaw + networks: + - pms-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:18789/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + +volumes: + openclaw-config: networks: pms-network: diff --git a/frontend/app/(main)/admin/aiAssistant/agents/page.tsx b/frontend/app/(main)/admin/aiAssistant/agents/page.tsx new file mode 100644 index 00000000..7ade805e --- /dev/null +++ b/frontend/app/(main)/admin/aiAssistant/agents/page.tsx @@ -0,0 +1,416 @@ +"use client"; + +import { useEffect, useState, useCallback, useRef } from "react"; +import { aiAgentApi } from "@/lib/api/aiAgent"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Bot, Plus, Search, Pencil, Trash2, Loader2, FileText, Upload, X, Settings2 } from "lucide-react"; +import { toast } from "sonner"; + +const MODEL_GROUPS = [ + { provider: "Anthropic", color: "#D97757", models: [ + { value: "claude-opus-4-20250514", label: "Claude Opus 4" }, + { value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" }, + { value: "claude-haiku-4-20250514", label: "Claude Haiku 4" }, + { value: "claude-sonnet-4-5-20250514", label: "Claude Sonnet 4.5" }, + ]}, + { provider: "OpenAI", color: "#10A37F", models: [ + { value: "gpt-4o", label: "GPT-4o" }, + { value: "gpt-4o-mini", label: "GPT-4o Mini" }, + { value: "o1", label: "o1" }, + { value: "o3-mini", label: "o3 Mini" }, + ]}, + { provider: "Google", color: "#4285F4", models: [ + { value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + { value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, + { value: "gemini-2.0-flash", label: "Gemini 2.0 Flash (무료)" }, + ]}, + { provider: "DeepSeek", color: "#0066FF", models: [ + { value: "deepseek-chat", label: "DeepSeek Chat (V3)" }, + { value: "deepseek-reasoner", label: "DeepSeek Reasoner (R1)" }, + ]}, + { provider: "Ollama (로컬)", color: "#1D1D1D", models: [ + { value: "llama3.2", label: "Llama 3.2" }, + { value: "mistral", label: "Mistral" }, + { value: "qwen2.5", label: "Qwen 2.5" }, + ]}, +]; + +function getModelColor(model: string): string { + for (const g of MODEL_GROUPS) { if (g.models.some((m) => m.value === model)) return g.color; } + return "#6B7280"; +} +function getModelLabel(model: string): string { + for (const g of MODEL_GROUPS) { const m = g.models.find((m) => m.value === model); if (m) return m.label; } + return model; +} + +interface Agent { + id: number; agent_id: string; name: string; description?: string; model: string; + system_prompt?: string; config: Record; status: string; created_at: string; +} + +interface AgentForm { + name: string; description: string; model: string; system_prompt: string; + config: { temperature: number; max_tokens: number; knowledge_files: Array<{ name: string; content: string }> }; +} + +const DEFAULT_FORM: AgentForm = { + name: "", description: "", model: "claude-sonnet-4-20250514", system_prompt: "", + config: { temperature: 0.7, max_tokens: 4096, knowledge_files: [] }, +}; + +export default function AgentListPage() { + const [agents, setAgents] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState({ ...DEFAULT_FORM }); + const [saving, setSaving] = useState(false); + const fileInputRef = useRef(null); + const [libraryFiles, setLibraryFiles] = useState([]); + const [libraryLoading, setLibraryLoading] = useState(false); + + const loadLibrary = useCallback(async () => { + setLibraryLoading(true); + try { const res = await aiAgentApi.listKnowledge(); setLibraryFiles(res.data || []); } + catch { /* 라이브러리 없어도 무시 */ } + setLibraryLoading(false); + }, []); + + const loadAgents = useCallback(async () => { + setLoading(true); + try { const res = await aiAgentApi.list({ search: search || undefined }); setAgents(res.data || []); } + catch { toast.error("에이전트 목록 로드 실패"); } + setLoading(false); + }, [search]); + + useEffect(() => { loadAgents(); }, [loadAgents]); + + const openCreate = () => { + setEditing(null); + setForm({ ...DEFAULT_FORM, config: { ...DEFAULT_FORM.config, knowledge_files: [] } }); + loadLibrary(); + setModalOpen(true); + }; + + const openEdit = (agent: Agent) => { + setEditing(agent); + const cfg = agent.config || {}; + setForm({ + name: agent.name, description: agent.description || "", model: agent.model, + system_prompt: agent.system_prompt || "", + config: { temperature: cfg.temperature ?? 0.7, max_tokens: cfg.max_tokens ?? 4096, knowledge_files: cfg.knowledge_files || [] }, + }); + loadLibrary(); + setModalOpen(true); + }; + + const addLibraryFile = async (libFile: any) => { + try { + const res = await aiAgentApi.getKnowledge(libFile.id); + const file = res.data; + const alreadyExists = form.config.knowledge_files.some((f) => f.name === file.name); + if (alreadyExists) { toast.error("이미 추가된 파일입니다."); return; } + setForm((prev) => ({ + ...prev, + config: { + ...prev.config, + knowledge_files: [...prev.config.knowledge_files, { name: file.name, content: file.content, source: "library", library_id: file.id }], + }, + })); + toast.success(`${file.name} 추가됨`); + } catch { toast.error("파일 불러오기 실패"); } + }; + + const handleSave = async () => { + if (!form.name) { toast.error("에이전트 이름은 필수입니다."); return; } + setSaving(true); + try { + if (editing) { await aiAgentApi.update(editing.id, form); toast.success("수정 완료"); } + else { await aiAgentApi.create({ ...form, agent_id: `agent-${crypto.randomUUID().split("-")[0]}` }); toast.success("생성 완료"); } + setModalOpen(false); loadAgents(); + } catch (e: any) { toast.error(e.response?.data?.message || "저장 실패"); } + setSaving(false); + }; + + const handleDelete = async (id: number) => { + if (!confirm("에이전트를 삭제하시겠습니까?")) return; + try { await aiAgentApi.delete(id); toast.success("삭제 완료"); loadAgents(); } + catch { toast.error("삭제 실패"); } + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files) return; + Array.from(files).forEach((file) => { + const reader = new FileReader(); + reader.onload = async (ev) => { + const content = (ev.target?.result as string).substring(0, 50000); + const fileName = file.name; + const name = fileName.replace(/\.[^.]+$/, ""); + + try { + const libRes = await aiAgentApi.createKnowledge({ + name, + file_name: fileName, + category: "공통", + description: `${editing?.name || form.name || "에이전트"} 설정에서 업로드`, + content, + }); + const libraryId = libRes.data?.id; + + setForm((prev) => ({ + ...prev, + config: { + ...prev.config, + knowledge_files: [...prev.config.knowledge_files, { name, content, source: "library", library_id: libraryId }], + }, + })); + toast.success(`${fileName} → 라이브러리 등록 + 에이전트 적용`); + loadLibrary(); + } catch { + setForm((prev) => ({ + ...prev, + config: { ...prev.config, knowledge_files: [...prev.config.knowledge_files, { name, content }] }, + })); + toast.success(`${fileName} 추가됨 (라이브러리 등록은 실패)`); + } + }; + reader.readAsText(file); + }); + e.target.value = ""; + }; + + const removeFile = (idx: number) => { + setForm((prev) => ({ ...prev, config: { ...prev.config, knowledge_files: prev.config.knowledge_files.filter((_, i) => i !== idx) } })); + }; + + return ( +
+
+
+
+

AI 에이전트

+

모델 선택, 프롬프트, 지식 파일을 설정합니다. 데이터 연결은 워크스페이스에서 합니다.

+
+ +
+ +
+ + setSearch(e.target.value)} className="h-8 pl-8 text-xs" /> +
+ + {loading ? ( +
+ ) : agents.length === 0 ? ( +
+ +

등록된 에이전트가 없습니다

+
+ ) : ( +
+ {agents.map((agent) => { + const color = getModelColor(agent.model); + const cfg = agent.config || {}; + const fileCount = (cfg.knowledge_files || []).length; + return ( +
+
+
+
+ +
+
+

{agent.name}

+

{agent.agent_id}

+
+
+ {agent.status} +
+ {agent.description &&

{agent.description}

} +
+
+ + {getModelLabel(agent.model)} +
+
+ {agent.system_prompt && 프롬프트 설정됨} + {fileCount > 0 && 지식 {fileCount}} +
+
+
+ + +
+
+ ); + })} +
+ )} +
+ + + + {editing ? "에이전트 설정" : "에이전트 추가"} + + + 기본 + 프롬프트 & 지식 + 파라미터 + + + +
+ + setForm({ ...form, name: e.target.value })} placeholder="예: 설계 분석기" className="mt-1 h-8 text-xs" /> +
+
+ + setForm({ ...form, description: e.target.value })} placeholder="간단한 설명" className="mt-1 h-8 text-xs" /> +
+
+ + +
+
+ + +
+ +