diff --git a/ai-assistant/.env.example b/ai-assistant/.env.example new file mode 100644 index 00000000..3ecc1ba0 --- /dev/null +++ b/ai-assistant/.env.example @@ -0,0 +1,25 @@ +# AI Assistant API (VEXPLOR 내장) - 환경 변수 +# 이 파일을 .env 로 복사한 뒤 값 설정 + +NODE_ENV=development +PORT=3100 + +# PostgreSQL (AI 어시스턴트 전용 DB) +DB_HOST=localhost +DB_PORT=5432 +DB_USER=ai_assistant +DB_PASSWORD=ai_assistant_password +DB_NAME=ai_assistant_db + +# JWT +JWT_SECRET=your-super-secret-jwt-key-change-in-production +JWT_EXPIRES_IN=7d +JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production +JWT_REFRESH_EXPIRES_IN=30d + +# LLM (구글 키 등) +GEMINI_API_KEY=your-gemini-api-key +GEMINI_MODEL=gemini-2.0-flash + +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX_REQUESTS=100 diff --git a/ai-assistant/Dockerfile.win b/ai-assistant/Dockerfile.win new file mode 100644 index 00000000..f652b312 --- /dev/null +++ b/ai-assistant/Dockerfile.win @@ -0,0 +1,17 @@ +# 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 new file mode 100644 index 00000000..8eb5c2ec --- /dev/null +++ b/ai-assistant/README.md @@ -0,0 +1,43 @@ +# AI 어시스턴트 API (VEXPLOR 내장) + +VEXPLOR와 **같은 서비스**로 동작하도록 이 API는 포트 3100에서 구동되고, backend-node가 `/api/ai/v1` 요청을 여기로 프록시합니다. + +## 동작 방식 + +- **프론트(9771)** → `/api/ai/v1/*` 호출 +- **Next.js** → `8080/api/ai/v1/*` 로 rewrite +- **backend-node(8080)** → `3100/api/v1/*` 로 프록시 → **이 서비스** + +따라서 사용자는 **다른 포트를 쓰지 않고** VEXPLOR만 켜도 AI 기능을 사용할 수 있습니다. + +## 서비스 올리는 순서 (한 번에 동작하게) + +1. **AI 어시스턴트 API (이 폴더, 포트 3100)** + ```bash + cd ai-assistant + npm install + cp .env.example .env # 필요 시 DB, JWT, GEMINI_API_KEY 등 수정 + npm start + ``` + +2. **backend-node (포트 8080)** + ```bash + cd backend-node + npm run dev + ``` + +3. **프론트 (포트 9771)** + ```bash + cd frontend + npm run dev + ``` + +브라우저에서는 `http://localhost:9771` 만 사용하면 되고, AI API는 같은 오리진의 `/api/ai/v1` 로 호출됩니다. + +## 환경 변수 + +- `.env.example` 을 `.env` 로 복사 후 수정 +- `PORT=3100` (기본값) +- PostgreSQL: `DB_*` +- JWT: `JWT_SECRET`, `JWT_REFRESH_SECRET` +- LLM: `GEMINI_API_KEY` 등 diff --git a/ai-assistant/package-lock.json b/ai-assistant/package-lock.json new file mode 100644 index 00000000..30eef7bc --- /dev/null +++ b/ai-assistant/package-lock.json @@ -0,0 +1,3453 @@ +{ + "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", + "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", + "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 new file mode 100644 index 00000000..58fcec47 --- /dev/null +++ b/ai-assistant/package.json @@ -0,0 +1,38 @@ +{ + "name": "ai-assistant-api", + "version": "1.0.0", + "description": "AI Assistant API (VEXPLOR 내장) - 포트 3100에서 구동, backend-node가 /api/ai/v1 로 프록시", + "private": true, + "main": "src/app.js", + "scripts": { + "start": "node src/app.js", + "dev": "nodemon src/app.js" + }, + "dependencies": { + "@google/genai": "^1.0.0", + "axios": "^1.6.0", + "bcryptjs": "^2.4.3", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.4.1", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "express-validator": "^7.0.1", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "pg": "^8.11.3", + "pg-hstore": "^2.3.4", + "sequelize": "^6.35.2", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", + "uuid": "^9.0.1", + "winston": "^3.11.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "nodemon": "^3.0.3" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/ai-assistant/src/app.js b/ai-assistant/src/app.js new file mode 100644 index 00000000..845217c6 --- /dev/null +++ b/ai-assistant/src/app.js @@ -0,0 +1,186 @@ +// src/app.js +// AI Assistant API 서버 메인 엔트리포인트 + +require('dotenv').config(); + +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +const compression = require('compression'); +const rateLimit = require('express-rate-limit'); +const swaggerUi = require('swagger-ui-express'); +const swaggerSpec = require('./config/swagger.config'); + +const logger = require('./config/logger.config'); +const { sequelize } = require('./models'); +const routes = require('./routes'); +const errorHandler = require('./middlewares/error-handler.middleware'); + +const app = express(); +// VEXPLOR 내장 시 backend-node가 이 포트로 프록시하므로 기본 3100 사용 +const PORT = process.env.PORT || 3100; + +// =========================================== +// 미들웨어 설정 +// =========================================== + +// Trust proxy (Docker/Nginx 환경) +app.set('trust proxy', 1); + +// CORS 설정 (helmet보다 먼저 설정) +app.use(cors({ + origin: true, // 모든 origin 허용 + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], +})); + +// Preflight 요청 처리 +app.options('*', cors()); + +// 보안 헤더 (CORS 이후에 설정) +app.use(helmet({ + crossOriginResourcePolicy: { policy: 'cross-origin' }, + crossOriginOpenerPolicy: { policy: 'unsafe-none' }, +})); + +// 요청 본문 파싱 +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true })); + +// 압축 +app.use(compression()); + +// Rate Limiting (전역) +const limiter = rateLimit({ + windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000, + max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 100, + message: { + success: false, + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: '요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.', + }, + }, + standardHeaders: true, + legacyHeaders: false, +}); +app.use(limiter); + +// 요청 로깅 +app.use((req, res, next) => { + const start = Date.now(); + res.on('finish', () => { + const duration = Date.now() - start; + logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`); + }); + next(); +}); + +// =========================================== +// 헬스 체크 +// =========================================== + +app.get('/health', (req, res) => { + res.json({ + success: true, + data: { + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + }, + }); +}); + +// =========================================== +// Swagger API 문서 +// =========================================== + +app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { + explorer: true, + customCss: '.swagger-ui .topbar { display: none }', + customSiteTitle: 'AI Assistant API 문서', + swaggerOptions: { + persistAuthorization: true, + displayRequestDuration: true, + }, +})); + +// Swagger JSON +app.get('/api-docs.json', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.send(swaggerSpec); +}); + +// =========================================== +// API 라우트 +// =========================================== + +app.use('/api/v1', routes); + +// =========================================== +// 404 처리 +// =========================================== + +app.use((req, res) => { + res.status(404).json({ + success: false, + error: { + code: 'NOT_FOUND', + message: `요청한 리소스를 찾을 수 없습니다: ${req.method} ${req.originalUrl}`, + }, + }); +}); + +// =========================================== +// 에러 핸들러 +// =========================================== + +app.use(errorHandler); + +// =========================================== +// 서버 시작 +// =========================================== + +async function startServer() { + try { + // 데이터베이스 연결 + await sequelize.authenticate(); + logger.info('✅ 데이터베이스 연결 성공'); + + // 테이블 동기화 (테이블이 없으면 생성) + await sequelize.sync(); + logger.info('✅ 데이터베이스 스키마 동기화 완료'); + + // 초기 데이터 설정 (관리자 계정, LLM 프로바이더) + const initService = require('./services/init.service'); + await initService.initialize(); + + // 서버 시작 + app.listen(PORT, () => { + logger.info(`🚀 AI Assistant API 서버가 포트 ${PORT}에서 실행 중입니다`); + logger.info(`📚 API 문서 (Swagger): http://localhost:${PORT}/api-docs`); + logger.info(`📚 API 엔드포인트: http://localhost:${PORT}/api/v1`); + }); + } catch (error) { + logger.error('❌ 서버 시작 실패:', error); + process.exit(1); + } +} + +// 프로세스 종료 처리 +process.on('SIGTERM', async () => { + logger.info('SIGTERM 신호 수신, 서버 종료 중...'); + await sequelize.close(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + logger.info('SIGINT 신호 수신, 서버 종료 중...'); + await sequelize.close(); + process.exit(0); +}); + +startServer(); + +module.exports = app; diff --git a/ai-assistant/src/controllers/admin.controller.js b/ai-assistant/src/controllers/admin.controller.js new file mode 100644 index 00000000..6973a892 --- /dev/null +++ b/ai-assistant/src/controllers/admin.controller.js @@ -0,0 +1,474 @@ +// 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 new file mode 100644 index 00000000..85d8061c --- /dev/null +++ b/ai-assistant/src/controllers/api-key.controller.js @@ -0,0 +1,215 @@ +// 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 new file mode 100644 index 00000000..73746415 --- /dev/null +++ b/ai-assistant/src/controllers/auth.controller.js @@ -0,0 +1,195 @@ +// 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 new file mode 100644 index 00000000..e93d2f98 --- /dev/null +++ b/ai-assistant/src/controllers/chat.controller.js @@ -0,0 +1,152 @@ +// 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 new file mode 100644 index 00000000..68df5824 --- /dev/null +++ b/ai-assistant/src/controllers/model.controller.js @@ -0,0 +1,67 @@ +// 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 new file mode 100644 index 00000000..ba436919 --- /dev/null +++ b/ai-assistant/src/controllers/usage.controller.js @@ -0,0 +1,177 @@ +// 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 new file mode 100644 index 00000000..b498478d --- /dev/null +++ b/ai-assistant/src/controllers/user.controller.js @@ -0,0 +1,113 @@ +// 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 new file mode 100644 index 00000000..bb19f293 --- /dev/null +++ b/ai-assistant/src/middlewares/auth.middleware.js @@ -0,0 +1,257 @@ +// 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 new file mode 100644 index 00000000..56acce44 --- /dev/null +++ b/ai-assistant/src/middlewares/error-handler.middleware.js @@ -0,0 +1,80 @@ +// 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 new file mode 100644 index 00000000..10096f87 --- /dev/null +++ b/ai-assistant/src/middlewares/usage-logger.middleware.js @@ -0,0 +1,50 @@ +// 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 new file mode 100644 index 00000000..23bf035f --- /dev/null +++ b/ai-assistant/src/middlewares/validation.middleware.js @@ -0,0 +1,30 @@ +// 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 new file mode 100644 index 00000000..772b6715 --- /dev/null +++ b/ai-assistant/src/models/api-key.model.js @@ -0,0 +1,130 @@ +// 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 new file mode 100644 index 00000000..45f79258 --- /dev/null +++ b/ai-assistant/src/models/index.js @@ -0,0 +1,55 @@ +// 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 new file mode 100644 index 00000000..68b0e443 --- /dev/null +++ b/ai-assistant/src/models/llm-provider.model.js @@ -0,0 +1,143 @@ +// 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 new file mode 100644 index 00000000..161e7a1f --- /dev/null +++ b/ai-assistant/src/models/usage-log.model.js @@ -0,0 +1,164 @@ +// 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 new file mode 100644 index 00000000..85fe08a5 --- /dev/null +++ b/ai-assistant/src/models/user.model.js @@ -0,0 +1,92 @@ +// 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 new file mode 100644 index 00000000..abb2854f --- /dev/null +++ b/ai-assistant/src/routes/admin.routes.js @@ -0,0 +1,151 @@ +// 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 new file mode 100644 index 00000000..c45b2d9c --- /dev/null +++ b/ai-assistant/src/routes/api-key.routes.js @@ -0,0 +1,99 @@ +// 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 new file mode 100644 index 00000000..416b0c52 --- /dev/null +++ b/ai-assistant/src/routes/auth.routes.js @@ -0,0 +1,76 @@ +// 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 new file mode 100644 index 00000000..ac8d1ae3 --- /dev/null +++ b/ai-assistant/src/routes/chat.routes.js @@ -0,0 +1,55 @@ +// 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 new file mode 100644 index 00000000..ddcd9158 --- /dev/null +++ b/ai-assistant/src/routes/index.js @@ -0,0 +1,45 @@ +// 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 new file mode 100644 index 00000000..edd828c8 --- /dev/null +++ b/ai-assistant/src/routes/model.routes.js @@ -0,0 +1,24 @@ +// 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 new file mode 100644 index 00000000..e8cd0049 --- /dev/null +++ b/ai-assistant/src/routes/usage.routes.js @@ -0,0 +1,81 @@ +// 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 new file mode 100644 index 00000000..953c9ebe --- /dev/null +++ b/ai-assistant/src/routes/user.routes.js @@ -0,0 +1,50 @@ +// 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 new file mode 100644 index 00000000..02b95d9f --- /dev/null +++ b/ai-assistant/src/seeders/001-llm-providers.js @@ -0,0 +1,74 @@ +// 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 new file mode 100644 index 00000000..65993d3d --- /dev/null +++ b/ai-assistant/src/services/init.service.js @@ -0,0 +1,128 @@ +// 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 new file mode 100644 index 00000000..fd17f16d --- /dev/null +++ b/ai-assistant/src/services/llm.service.js @@ -0,0 +1,385 @@ +// 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 new file mode 100644 index 00000000..6518aaca --- /dev/null +++ b/ai-assistant/src/swagger/api-docs.js @@ -0,0 +1,359 @@ +// src/swagger/api-docs.js +// Swagger API 문서 정의 + +/** + * @swagger + * /auth/register: + * post: + * tags: [Auth] + * summary: 회원가입 + * description: 새 계정을 생성합니다. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, password, name] + * properties: + * email: + * type: string + * format: email + * example: user@example.com + * password: + * type: string + * minLength: 8 + * example: Password123! + * description: 8자 이상, 영문/숫자/특수문자 포함 + * name: + * type: string + * example: 홍길동 + * responses: + * 201: + * description: 회원가입 성공 + * 400: + * description: 유효성 검사 실패 + * 409: + * description: 이미 존재하는 이메일 + */ + +/** + * @swagger + * /auth/login: + * post: + * tags: [Auth] + * summary: 로그인 + * description: 이메일과 비밀번호로 로그인합니다. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, password] + * properties: + * email: + * type: string + * format: email + * example: admin@admin.com + * password: + * type: string + * example: Admin123! + * responses: + * 200: + * description: 로그인 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: object + * properties: + * user: + * type: object + * accessToken: + * type: string + * description: JWT 액세스 토큰 + * refreshToken: + * type: string + * description: JWT 리프레시 토큰 + * 401: + * description: 인증 실패 + */ + +/** + * @swagger + * /chat/completions: + * post: + * tags: [Chat] + * summary: 채팅 완성 (OpenAI 호환) + * description: | + * AI 모델에 메시지를 보내고 응답을 받습니다. + * OpenAI API와 호환되는 형식입니다. + * + * **인증**: JWT 토큰 또는 API 키 (sk-xxx) + * security: + * - BearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ChatCompletionRequest' + * examples: + * simple: + * summary: 간단한 질문 + * value: + * model: gemini-2.0-flash + * messages: + * - role: user + * content: 안녕하세요! + * with_system: + * summary: 시스템 프롬프트 포함 + * value: + * model: gemini-2.0-flash + * messages: + * - role: system + * content: 당신은 친절한 AI 어시스턴트입니다. + * - role: user + * content: 파이썬으로 Hello World 출력하는 코드 알려줘 + * temperature: 0.7 + * max_tokens: 1000 + * responses: + * 200: + * description: 성공 + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ChatCompletionResponse' + * 401: + * description: 인증 실패 + * 429: + * description: 요청 한도 초과 + */ + +/** + * @swagger + * /models: + * get: + * tags: [Models] + * summary: 모델 목록 조회 + * description: 사용 가능한 AI 모델 목록을 조회합니다. + * security: + * - BearerAuth: [] + * responses: + * 200: + * description: 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * object: + * type: string + * example: list + * data: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * example: gemini-2.0-flash + * object: + * type: string + * example: model + * owned_by: + * type: string + * example: google + */ + +/** + * @swagger + * /api-keys: + * get: + * tags: [API Keys] + * summary: API 키 목록 조회 + * description: 발급받은 API 키 목록을 조회합니다. + * security: + * - BearerAuth: [] + * responses: + * 200: + * description: 성공 + * post: + * tags: [API Keys] + * summary: API 키 발급 + * description: 새 API 키를 발급받습니다. 키는 한 번만 표시됩니다. + * security: + * - BearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [name] + * properties: + * name: + * type: string + * example: My API Key + * description: API 키 이름 + * responses: + * 201: + * description: 발급 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * key: + * type: string + * description: 발급된 API 키 (한 번만 표시) + * example: sk-abc123def456... + */ + +/** + * @swagger + * /api-keys/{id}: + * delete: + * tags: [API Keys] + * summary: API 키 폐기 + * description: API 키를 폐기합니다. + * security: + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: API 키 ID + * responses: + * 200: + * description: 폐기 성공 + * 404: + * description: API 키를 찾을 수 없음 + */ + +/** + * @swagger + * /usage: + * get: + * tags: [Usage] + * summary: 사용량 요약 조회 + * description: 오늘/이번 달 사용량 요약을 조회합니다. + * security: + * - BearerAuth: [] + * responses: + * 200: + * description: 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * plan: + * type: string + * example: free + * limit: + * type: object + * properties: + * monthly: + * type: integer + * remaining: + * type: integer + * usage: + * type: object + * properties: + * today: + * type: object + * monthly: + * type: object + */ + +/** + * @swagger + * /usage/logs: + * get: + * tags: [Usage] + * summary: 사용 로그 조회 + * description: API 호출 로그를 조회합니다. + * security: + * - BearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * responses: + * 200: + * description: 성공 + */ + +/** + * @swagger + * /admin/users: + * get: + * tags: [Admin] + * summary: 사용자 목록 조회 (관리자) + * description: 모든 사용자 목록을 조회합니다. 관리자 권한 필요. + * security: + * - BearerAuth: [] + * responses: + * 200: + * description: 성공 + * 403: + * description: 권한 없음 + */ + +/** + * @swagger + * /admin/providers: + * get: + * tags: [Admin] + * summary: LLM 프로바이더 목록 (관리자) + * description: LLM 프로바이더 설정을 조회합니다. 관리자 권한 필요. + * security: + * - BearerAuth: [] + * responses: + * 200: + * description: 성공 + * 403: + * description: 권한 없음 + */ + +/** + * @swagger + * /admin/stats: + * get: + * tags: [Admin] + * summary: 시스템 통계 (관리자) + * description: 시스템 전체 통계를 조회합니다. 관리자 권한 필요. + * security: + * - BearerAuth: [] + * responses: + * 200: + * description: 성공 + * 403: + * description: 권한 없음 + */ diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index ae55e3c4..f482dc7b 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -22,6 +22,7 @@ "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "html-to-docx": "^1.8.0", + "http-proxy-middleware": "^3.0.5", "iconv-lite": "^0.7.0", "imap": "^0.8.19", "joi": "^17.11.0", @@ -3318,6 +3319,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/imap": { "version": "0.8.42", "resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz", @@ -4419,7 +4429,6 @@ "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" @@ -6154,7 +6163,6 @@ "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" @@ -6887,6 +6895,20 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -6900,6 +6922,29 @@ "node": ">= 14" } }, + "node_modules/http-proxy-middleware": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", + "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.15", + "debug": "^4.3.6", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.3", + "is-plain-object": "^5.0.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/http-proxy/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -7208,7 +7253,6 @@ "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" @@ -7238,7 +7282,6 @@ "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" @@ -7269,7 +7312,6 @@ "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" @@ -7294,6 +7336,15 @@ "node": ">=8" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -8566,7 +8617,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -9388,7 +9438,6 @@ "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" @@ -9946,6 +9995,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -10824,7 +10879,6 @@ "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" diff --git a/backend-node/package.json b/backend-node/package.json index 310ab401..53ee00b8 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -36,6 +36,7 @@ "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "html-to-docx": "^1.8.0", + "http-proxy-middleware": "^3.0.5", "iconv-lite": "^0.7.0", "imap": "^0.8.19", "joi": "^17.11.0", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 24ba1647..ad4ced77 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -16,14 +16,17 @@ import { refreshTokenIfNeeded } from "./middleware/authMiddleware"; // ============================================ // 처리되지 않은 Promise 거부 핸들러 -process.on("unhandledRejection", (reason: Error | any, promise: Promise) => { - logger.error("⚠️ Unhandled Promise Rejection:", { - reason: reason?.message || reason, - stack: reason?.stack, - }); - // 프로세스를 종료하지 않고 로깅만 수행 - // 심각한 에러의 경우 graceful shutdown 고려 -}); +process.on( + "unhandledRejection", + (reason: Error | any, promise: Promise) => { + logger.error("⚠️ Unhandled Promise Rejection:", { + reason: reason?.message || reason, + stack: reason?.stack, + }); + // 프로세스를 종료하지 않고 로깅만 수행 + // 심각한 에러의 경우 graceful shutdown 고려 + }, +); // 처리되지 않은 예외 핸들러 process.on("uncaughtException", (error: Error) => { @@ -38,13 +41,16 @@ process.on("uncaughtException", (error: Error) => { // SIGTERM 시그널 처리 (Docker/Kubernetes 환경) process.on("SIGTERM", () => { logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작..."); - // 여기서 연결 풀 정리 등 cleanup 로직 추가 가능 + const { stopAiAssistant } = require("./utils/startAiAssistant"); + stopAiAssistant(); process.exit(0); }); // SIGINT 시그널 처리 (Ctrl+C) process.on("SIGINT", () => { logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작..."); + const { stopAiAssistant } = require("./utils/startAiAssistant"); + stopAiAssistant(); process.exit(0); }); @@ -112,7 +118,9 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합 import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리 -import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션 +import entitySearchRoutes, { + entityOptionsRouter, +} from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행 @@ -128,6 +136,7 @@ import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다 import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계 import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트) import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준 +import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스) import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 @@ -152,7 +161,7 @@ app.use( ], // 프론트엔드 도메인 허용 }, }, - }) + }), ); app.use(compression()); app.use(express.json({ limit: "10mb" })); @@ -175,13 +184,13 @@ app.use( res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); res.setHeader( "Access-Control-Allow-Headers", - "Content-Type, Authorization" + "Content-Type, Authorization", ); res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); res.setHeader("Cache-Control", "public, max-age=3600"); next(); }, - express.static(path.join(process.cwd(), "uploads")) + express.static(path.join(process.cwd(), "uploads")), ); // CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨 @@ -201,7 +210,7 @@ app.use( ], preflightContinue: false, optionsSuccessStatus: 200, - }) + }), ); // Rate Limiting (개발 환경에서는 완화) @@ -318,6 +327,7 @@ app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테 app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준 app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 +app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 app.use("/api/approval", approvalRoutes); // 결재 시스템 // app.use("/api/collections", collectionRoutes); // 임시 주석 @@ -406,6 +416,14 @@ app.listen(PORT, HOST, async () => { } catch (error) { logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error); } + + // AI 어시스턴트 서비스 함께 기동 (한 번에 킬 가능) + try { + const { startAiAssistant } = await import("./utils/startAiAssistant"); + startAiAssistant(); + } catch (error) { + logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error); + } }); export default app; diff --git a/backend-node/src/routes/aiAssistantProxy.ts b/backend-node/src/routes/aiAssistantProxy.ts new file mode 100644 index 00000000..64012976 --- /dev/null +++ b/backend-node/src/routes/aiAssistantProxy.ts @@ -0,0 +1,31 @@ +/** + * AI 어시스턴트 API 프록시 + * - /api/ai/v1/* 요청을 AI 서비스(기본 3100 포트)로 전달 + * - VEXPLOR와 같은 서비스로 쓰려면: 프론트(9771) → 백엔드(8080) → 여기서 3100으로 프록시 + */ +import { createProxyMiddleware } from "http-proxy-middleware"; +import type { RequestHandler } from "express"; + +const AI_SERVICE_URL = + process.env.AI_ASSISTANT_SERVICE_URL || "http://127.0.0.1:3100"; + +const aiAssistantProxy: RequestHandler = createProxyMiddleware({ + target: AI_SERVICE_URL, + changeOrigin: true, + pathRewrite: { "^/api/ai/v1": "/api/v1" }, + // 대상 서비스 미기동 시 502 등 에러 처리 (v3 타입에 없을 수 있음) + onError: (_err, _req, res) => { + if (!res.headersSent) { + res.status(502).json({ + success: false, + error: { + code: "AI_SERVICE_UNAVAILABLE", + message: + "AI 어시스턴트 서비스를 사용할 수 없습니다. AI 서비스(기본 3100 포트)를 기동한 뒤 다시 시도하세요.", + }, + }); + } + }, +} as Parameters[0]); + +export default aiAssistantProxy; diff --git a/backend-node/src/routes/popActionRoutes.ts b/backend-node/src/routes/popActionRoutes.ts index 24ef3af0..730572d8 100644 --- a/backend-node/src/routes/popActionRoutes.ts +++ b/backend-node/src/routes/popActionRoutes.ts @@ -2,6 +2,7 @@ import { Router, Request, Response } from "express"; import { getPool } from "../database/db"; import logger from "../utils/logger"; import { authenticateToken } from "../middleware/authMiddleware"; +import { numberingRuleService } from "../services/numberingRuleService"; const router = Router(); @@ -12,9 +13,26 @@ function isSafeIdentifier(name: string): boolean { return SAFE_IDENTIFIER.test(name); } +interface AutoGenMappingInfo { + numberingRuleId: string; + targetColumn: string; + showResultModal?: boolean; +} + +interface HiddenMappingInfo { + valueSource: "json_extract" | "db_column" | "static"; + targetColumn: string; + staticValue?: string; + sourceJsonColumn?: string; + sourceJsonKey?: string; + sourceDbColumn?: string; +} + interface MappingInfo { targetTable: string; columnMapping: Record; + autoGenMappings?: AutoGenMappingInfo[]; + hiddenMappings?: HiddenMappingInfo[]; } interface StatusConditionRule { @@ -44,7 +62,8 @@ interface StatusChangeRuleBody { } interface ExecuteActionBody { - action: string; + action?: string; + tasks?: TaskBody[]; data: { items?: Record[]; fieldValues?: Record; @@ -54,6 +73,36 @@ interface ExecuteActionBody { field?: MappingInfo | null; }; statusChanges?: StatusChangeRuleBody[]; + cartChanges?: { + toCreate?: Record[]; + toUpdate?: Record[]; + toDelete?: (string | number)[]; + }; +} + +interface TaskBody { + id: string; + type: string; + targetTable?: string; + targetColumn?: string; + operationType?: "assign" | "add" | "subtract" | "multiply" | "divide" | "conditional" | "db-conditional"; + valueSource?: "fixed" | "linked" | "reference"; + fixedValue?: string; + sourceField?: string; + referenceTable?: string; + referenceColumn?: string; + referenceJoinKey?: string; + conditionalValue?: ConditionalValueRule; + // db-conditional 전용 (DB 컬럼 간 비교 후 값 판정) + compareColumn?: string; + compareOperator?: "=" | "!=" | ">" | "<" | ">=" | "<="; + compareWith?: string; + dbThenValue?: string; + dbElseValue?: string; + lookupMode?: "auto" | "manual"; + manualItemField?: string; + manualPkColumn?: string; + cartScreenId?: string; } function resolveStatusValue( @@ -96,26 +145,300 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); } - const { action, data, mappings, statusChanges } = req.body as ExecuteActionBody; + const { action, tasks, data, mappings, statusChanges, cartChanges } = req.body as ExecuteActionBody; const items = data?.items ?? []; const fieldValues = data?.fieldValues ?? {}; logger.info("[pop/execute-action] 요청", { - action, + action: action ?? "task-list", companyCode, userId, itemCount: items.length, hasFieldValues: Object.keys(fieldValues).length > 0, hasMappings: !!mappings, statusChangeCount: statusChanges?.length ?? 0, + taskCount: tasks?.length ?? 0, + hasCartChanges: !!cartChanges, }); await client.query("BEGIN"); let processedCount = 0; let insertedCount = 0; + let deletedCount = 0; + const generatedCodes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = []; - if (action === "inbound-confirm") { + // ======== v2: tasks 배열 기반 처리 ======== + if (tasks && tasks.length > 0) { + for (const task of tasks) { + switch (task.type) { + case "data-save": { + // 매핑 기반 INSERT (기존 inbound-confirm INSERT 로직 재사용) + const cardMapping = mappings?.cardList; + const fieldMapping = mappings?.field; + + if (cardMapping?.targetTable && Object.keys(cardMapping.columnMapping).length > 0) { + if (!isSafeIdentifier(cardMapping.targetTable)) { + throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`); + } + + for (const item of items) { + const columns: string[] = ["company_code"]; + const values: unknown[] = [companyCode]; + + for (const [sourceField, targetColumn] of Object.entries(cardMapping.columnMapping)) { + if (!isSafeIdentifier(targetColumn)) continue; + columns.push(`"${targetColumn}"`); + values.push(item[sourceField] ?? null); + } + + if (fieldMapping?.targetTable === cardMapping.targetTable) { + for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) { + if (!isSafeIdentifier(targetColumn)) continue; + if (columns.includes(`"${targetColumn}"`)) continue; + columns.push(`"${targetColumn}"`); + values.push(fieldValues[sourceField] ?? null); + } + } + + const allHidden = [ + ...(fieldMapping?.hiddenMappings ?? []), + ...(cardMapping?.hiddenMappings ?? []), + ]; + for (const hm of allHidden) { + if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue; + if (columns.includes(`"${hm.targetColumn}"`)) continue; + let value: unknown = null; + if (hm.valueSource === "static") { + value = hm.staticValue ?? null; + } else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) { + const jsonCol = item[hm.sourceJsonColumn]; + if (typeof jsonCol === "object" && jsonCol !== null) { + value = (jsonCol as Record)[hm.sourceJsonKey] ?? null; + } else if (typeof jsonCol === "string") { + try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ } + } + } else if (hm.valueSource === "db_column" && hm.sourceDbColumn) { + value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null; + } + columns.push(`"${hm.targetColumn}"`); + values.push(value); + } + + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + for (const ag of allAutoGen) { + if (!ag.numberingRuleId || !ag.targetColumn) continue; + if (!isSafeIdentifier(ag.targetColumn)) continue; + if (columns.includes(`"${ag.targetColumn}"`)) continue; + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, companyCode, { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message }); + } + } + + if (columns.length > 1) { + const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); + await client.query( + `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`, + values, + ); + insertedCount++; + } + } + } + break; + } + + case "data-update": { + if (!task.targetTable || !task.targetColumn) break; + if (!isSafeIdentifier(task.targetTable) || !isSafeIdentifier(task.targetColumn)) break; + + const opType = task.operationType ?? "assign"; + const valSource = task.valueSource ?? "fixed"; + const lookupMode = task.lookupMode ?? "auto"; + + let itemField: string; + let pkColumn: string; + + if (lookupMode === "manual" && task.manualItemField && task.manualPkColumn) { + if (!isSafeIdentifier(task.manualPkColumn)) break; + itemField = task.manualItemField; + pkColumn = task.manualPkColumn; + } else if (task.targetTable === "cart_items") { + itemField = "__cart_id"; + pkColumn = "id"; + } else { + itemField = "__cart_row_key"; + const pkResult = await client.query( + `SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`, + [task.targetTable], + ); + pkColumn = pkResult.rows[0]?.attname || "id"; + } + + const lookupValues = items.map((item) => item[itemField] ?? item[itemField.replace(/^__cart_/, "")]).filter(Boolean); + if (lookupValues.length === 0) break; + + if (opType === "conditional" && task.conditionalValue) { + for (let i = 0; i < lookupValues.length; i++) { + const item = items[i] ?? {}; + const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item); + await client.query( + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`, + [resolved, companyCode, lookupValues[i]], + ); + processedCount++; + } + } else if (opType === "db-conditional") { + // DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중') + if (!task.compareColumn || !task.compareOperator || !task.compareWith) break; + if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break; + + const thenVal = task.dbThenValue ?? ""; + const elseVal = task.dbElseValue ?? ""; + const op = task.compareOperator; + const validOps = ["=", "!=", ">", "<", ">=", "<="]; + if (!validOps.includes(op)) break; + + const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`; + + const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", "); + await client.query( + `UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`, + [thenVal, elseVal, companyCode, ...lookupValues], + ); + processedCount += lookupValues.length; + } else { + for (let i = 0; i < lookupValues.length; i++) { + const item = items[i] ?? {}; + let value: unknown; + + if (valSource === "linked") { + value = item[task.sourceField ?? ""] ?? null; + } else { + value = task.fixedValue ?? ""; + } + + let setSql: string; + if (opType === "add") { + setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) + $1::numeric`; + } else if (opType === "subtract") { + setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) - $1::numeric`; + } else if (opType === "multiply") { + setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) * $1::numeric`; + } else if (opType === "divide") { + setSql = `"${task.targetColumn}" = CASE WHEN $1::numeric = 0 THEN COALESCE("${task.targetColumn}"::numeric, 0) ELSE COALESCE("${task.targetColumn}"::numeric, 0) / $1::numeric END`; + } else { + setSql = `"${task.targetColumn}" = $1`; + } + + await client.query( + `UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`, + [value, companyCode, lookupValues[i]], + ); + processedCount++; + } + } + + logger.info("[pop/execute-action] data-update 실행", { + table: task.targetTable, + column: task.targetColumn, + opType, + count: lookupValues.length, + }); + break; + } + + case "data-delete": { + if (!task.targetTable) break; + if (!isSafeIdentifier(task.targetTable)) break; + + const pkResult = await client.query( + `SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`, + [task.targetTable], + ); + const pkCol = pkResult.rows[0]?.attname || "id"; + const deleteKeys = items.map((item) => item[pkCol] ?? item["id"]).filter(Boolean); + + if (deleteKeys.length > 0) { + const placeholders = deleteKeys.map((_, i) => `$${i + 2}`).join(", "); + await client.query( + `DELETE FROM "${task.targetTable}" WHERE company_code = $1 AND "${pkCol}" IN (${placeholders})`, + [companyCode, ...deleteKeys], + ); + deletedCount += deleteKeys.length; + } + break; + } + + case "cart-save": { + // cartChanges 처리 (M-9에서 확장) + if (!cartChanges) break; + const { toCreate, toUpdate, toDelete } = cartChanges; + + if (toCreate && toCreate.length > 0) { + for (const item of toCreate) { + const cols = Object.keys(item).filter(isSafeIdentifier); + if (cols.length === 0) continue; + const allCols = ["company_code", ...cols.map((c) => `"${c}"`)]; + const allVals = [companyCode, ...cols.map((c) => item[c])]; + const placeholders = allVals.map((_, i) => `$${i + 1}`).join(", "); + await client.query( + `INSERT INTO "cart_items" (${allCols.join(", ")}) VALUES (${placeholders})`, + allVals, + ); + insertedCount++; + } + } + + if (toUpdate && toUpdate.length > 0) { + for (const item of toUpdate) { + const id = item.id; + if (!id) continue; + const cols = Object.keys(item).filter((c) => c !== "id" && isSafeIdentifier(c)); + if (cols.length === 0) continue; + const setClauses = cols.map((c, i) => `"${c}" = $${i + 3}`).join(", "); + await client.query( + `UPDATE "cart_items" SET ${setClauses} WHERE id = $1 AND company_code = $2`, + [id, companyCode, ...cols.map((c) => item[c])], + ); + processedCount++; + } + } + + if (toDelete && toDelete.length > 0) { + const placeholders = toDelete.map((_, i) => `$${i + 2}`).join(", "); + await client.query( + `DELETE FROM "cart_items" WHERE company_code = $1 AND id IN (${placeholders})`, + [companyCode, ...toDelete], + ); + deletedCount += toDelete.length; + } + + logger.info("[pop/execute-action] cart-save 실행", { + created: toCreate?.length ?? 0, + updated: toUpdate?.length ?? 0, + deleted: toDelete?.length ?? 0, + }); + break; + } + + default: + logger.warn("[pop/execute-action] 프론트 전용 작업 타입, 백엔드 무시", { type: task.type }); + } + } + } + // ======== v1 레거시: action 기반 처리 ======== + else if (action === "inbound-confirm") { // 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블) const cardMapping = mappings?.cardList; const fieldMapping = mappings?.field; @@ -144,6 +467,64 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp } } + // 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼) + const allHidden = [ + ...(fieldMapping?.hiddenMappings ?? []), + ...(cardMapping?.hiddenMappings ?? []), + ]; + for (const hm of allHidden) { + if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue; + if (columns.includes(`"${hm.targetColumn}"`)) continue; + + let value: unknown = null; + if (hm.valueSource === "static") { + value = hm.staticValue ?? null; + } else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) { + const jsonCol = item[hm.sourceJsonColumn]; + if (typeof jsonCol === "object" && jsonCol !== null) { + value = (jsonCol as Record)[hm.sourceJsonKey] ?? null; + } else if (typeof jsonCol === "string") { + try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ } + } + } else if (hm.valueSource === "db_column" && hm.sourceDbColumn) { + value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null; + } + + columns.push(`"${hm.targetColumn}"`); + values.push(value); + } + + // 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급 + const allAutoGen = [ + ...(fieldMapping?.autoGenMappings ?? []), + ...(cardMapping?.autoGenMappings ?? []), + ]; + for (const ag of allAutoGen) { + if (!ag.numberingRuleId || !ag.targetColumn) continue; + if (!isSafeIdentifier(ag.targetColumn)) continue; + if (columns.includes(`"${ag.targetColumn}"`)) continue; + try { + const generatedCode = await numberingRuleService.allocateCode( + ag.numberingRuleId, + companyCode, + { ...fieldValues, ...item }, + ); + columns.push(`"${ag.targetColumn}"`); + values.push(generatedCode); + generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false }); + logger.info("[pop/execute-action] 채번 완료", { + ruleId: ag.numberingRuleId, + targetColumn: ag.targetColumn, + generatedCode, + }); + } catch (err: any) { + logger.error("[pop/execute-action] 채번 실패", { + ruleId: ag.numberingRuleId, + error: err.message, + }); + } + } + if (columns.length > 1) { const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`; @@ -254,16 +635,17 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp await client.query("COMMIT"); logger.info("[pop/execute-action] 완료", { - action, + action: action ?? "task-list", companyCode, processedCount, insertedCount, + deletedCount, }); return res.json({ success: true, - message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`, - data: { processedCount, insertedCount }, + message: `${processedCount}건 처리${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}${deletedCount > 0 ? `, ${deletedCount}건 삭제` : ""}`, + data: { processedCount, insertedCount, deletedCount, generatedCodes }, }); } catch (error: any) { await client.query("ROLLBACK"); diff --git a/backend-node/src/utils/startAiAssistant.ts b/backend-node/src/utils/startAiAssistant.ts new file mode 100644 index 00000000..080df078 --- /dev/null +++ b/backend-node/src/utils/startAiAssistant.ts @@ -0,0 +1,65 @@ +/** + * AI 어시스턴트 서비스를 자식 프로세스로 기동 + * - backend-node 서버 기동 시 함께 띄우고, 종료 시 함께 종료 (한 번에 킬) + */ +import path from "path"; +import { spawn, ChildProcess } from "child_process"; +import { logger } from "./logger"; + +const AI_PORT = process.env.AI_ASSISTANT_SERVICE_PORT || "3100"; + +let aiAssistantProcess: ChildProcess | null = null; + +/** ERP-node/ai-assistant 경로 (backend-node 기준 상대) */ +function getAiAssistantDir(): string { + return path.resolve(process.cwd(), "..", "ai-assistant"); +} + +/** + * AI 어시스턴트 서비스 기동 (있으면 띄움, 실패해도 backend는 계속 동작) + */ +export function startAiAssistant(): void { + const aiDir = getAiAssistantDir(); + const appPath = path.join(aiDir, "src", "app.js"); + + try { + const fs = require("fs"); + if (!fs.existsSync(appPath)) { + logger.info(`⏭️ AI 어시스턴트 스킵 (경로 없음: ${appPath})`); + return; + } + } catch { + return; + } + + aiAssistantProcess = spawn("node", ["src/app.js"], { + cwd: aiDir, + stdio: "inherit", + env: { ...process.env, PORT: AI_PORT }, + shell: true, // Windows에서 node 경로 인식 + }); + + aiAssistantProcess.on("error", (err) => { + logger.warn(`⚠️ AI 어시스턴트 프로세스 에러: ${err.message}`); + }); + + aiAssistantProcess.on("exit", (code, signal) => { + aiAssistantProcess = null; + if (code != null && code !== 0) { + logger.warn(`⚠️ AI 어시스턴트 종료 (code=${code}, signal=${signal})`); + } + }); + + logger.info(`🤖 AI 어시스턴트 서비스 기동 (포트 ${AI_PORT}, cwd: ${aiDir})`); +} + +/** + * AI 어시스턴트 프로세스 종료 (SIGTERM/SIGINT 시 호출) + */ +export function stopAiAssistant(): void { + if (aiAssistantProcess && aiAssistantProcess.kill) { + aiAssistantProcess.kill("SIGTERM"); + aiAssistantProcess = null; + logger.info("🤖 AI 어시스턴트 프로세스 종료"); + } +} diff --git a/frontend/app/(main)/admin/aiAssistant/api-keys/page.tsx b/frontend/app/(main)/admin/aiAssistant/api-keys/page.tsx new file mode 100644 index 00000000..5b2ac043 --- /dev/null +++ b/frontend/app/(main)/admin/aiAssistant/api-keys/page.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { aiAssistantApi } from "@/lib/api/aiAssistant"; +import type { ApiKeyItem } from "@/lib/api/aiAssistant"; +import { + Key, + Plus, + Copy, + Trash2, + Loader2, + Check, + Eye, + EyeOff, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "sonner"; + +export default function AiAssistantApiKeysPage() { + const [loading, setLoading] = useState(true); + const [apiKeys, setApiKeys] = useState([]); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [newKeyDialogOpen, setNewKeyDialogOpen] = useState(false); + const [newKeyName, setNewKeyName] = useState(""); + const [newKey, setNewKey] = useState(""); + const [creating, setCreating] = useState(false); + const [showKey, setShowKey] = useState(false); + const [copied, setCopied] = useState(false); + + useEffect(() => { + loadApiKeys(); + }, []); + + const loadApiKeys = async () => { + setLoading(true); + try { + const res = await aiAssistantApi.get("/api-keys"); + setApiKeys(res.data?.data ?? []); + } catch { + toast.error("API 키 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const createApiKey = async () => { + if (!newKeyName.trim()) { + toast.error("키 이름을 입력해주세요."); + return; + } + setCreating(true); + try { + const res = await aiAssistantApi.post("/api-keys", { name: newKeyName }); + setNewKey((res.data?.data as { key?: string })?.key ?? ""); + setCreateDialogOpen(false); + setNewKeyDialogOpen(true); + setNewKeyName(""); + loadApiKeys(); + toast.success("API 키가 생성되었습니다."); + } catch (err: unknown) { + const msg = + err && typeof err === "object" && "response" in err + ? (err as { response?: { data?: { error?: { message?: string } } } }).response?.data + ?.error?.message + : null; + toast.error(msg ?? "API 키 생성에 실패했습니다."); + } finally { + setCreating(false); + } + }; + + const revokeApiKey = async (id: number) => { + if (!confirm("이 API 키를 폐기하시겠습니까?")) return; + try { + await aiAssistantApi.delete(`/api-keys/${id}`); + loadApiKeys(); + toast.success("API 키가 폐기되었습니다."); + } catch { + toast.error("API 키 폐기에 실패했습니다."); + } + }; + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + toast.success("클립보드에 복사되었습니다."); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("복사에 실패했습니다."); + } + }; + + const baseUrl = + typeof window !== "undefined" + ? process.env.NEXT_PUBLIC_AI_ASSISTANT_API_URL || "http://localhost:3100/api/v1" + : ""; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

API 키 관리

+

+ 외부 시스템에서 AI Assistant API를 사용하기 위한 키를 관리합니다. +

+
+ + + + + + + 새 API 키 생성 + + 새로운 API 키를 생성합니다. 키는 한 번만 표시되므로 안전하게 보관하세요. + + +
+
+ + setNewKeyName(e.target.value)} + /> +
+
+ + + + +
+
+
+ + + + + API 키가 생성되었습니다 + + 이 키는 다시 표시되지 않습니다. 안전한 곳에 복사하여 보관하세요. + + +
+
+ + + +
+
+ + + +
+
+ + + + API 키 목록 + 발급된 모든 API 키를 확인하고 관리합니다. + + + {apiKeys.length === 0 ? ( +
+ +

API 키가 없습니다

+

새 API 키를 생성하여 시작하세요.

+
+ ) : ( + + + + 이름 + + 상태 + 사용량 + 마지막 사용 + 생성일 + 작업 + + + + {apiKeys.map((key) => ( + + {key.name} + +
+ + {key.keyPrefix}... + + +
+
+ + + {key.status === "active" ? "활성" : "폐기됨"} + + + {(key.usageCount ?? 0).toLocaleString()} 토큰 + + {key.lastUsedAt + ? new Date(key.lastUsedAt).toLocaleDateString("ko-KR") + : "-"} + + {new Date(key.createdAt).toLocaleDateString("ko-KR")} + + {key.status === "active" && ( + + )} + +
+ ))} +
+
+ )} +
+
+ + + + API 사용 방법 + + 발급받은 API 키를 Authorization 헤더에 포함하여 요청하세요. + + + +
+            {`curl -X POST ${baseUrl}/chat/completions \\
+  -H "Content-Type: application/json" \\
+  -H "Authorization: Bearer YOUR_API_KEY" \\
+  -d '{"model": "gemini-2.0-flash", "messages": [{"role": "user", "content": "Hello!"}]}'`}
+          
+
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/aiAssistant/api-test/page.tsx b/frontend/app/(main)/admin/aiAssistant/api-test/page.tsx new file mode 100644 index 00000000..96898cb0 --- /dev/null +++ b/frontend/app/(main)/admin/aiAssistant/api-test/page.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { toast } from "sonner"; + +const DEFAULT_BASE = "http://localhost:3100/api/v1"; +const PRESETS = [ + { name: "채팅 완성", method: "POST", endpoint: "/chat/completions", body: '{"model":"gemini-2.0-flash","messages":[{"role":"user","content":"안녕하세요!"}],"temperature":0.7}' }, + { name: "모델 목록", method: "GET", endpoint: "/models", body: "" }, + { name: "사용량", method: "GET", endpoint: "/usage", body: "" }, + { name: "API 키 목록", method: "GET", endpoint: "/api-keys", body: "" }, +]; + +export default function AiAssistantApiTestPage() { + const [baseUrl, setBaseUrl] = useState( + typeof window !== "undefined" ? (process.env.NEXT_PUBLIC_AI_ASSISTANT_API_URL || DEFAULT_BASE) : DEFAULT_BASE + ); + const [apiKey, setApiKey] = useState(""); + const [method, setMethod] = useState("POST"); + const [endpoint, setEndpoint] = useState("/chat/completions"); + const [body, setBody] = useState(PRESETS[0].body); + const [loading, setLoading] = useState(false); + const [response, setResponse] = useState<{ status: number; statusText: string; data: unknown } | null>(null); + const [responseTime, setResponseTime] = useState(null); + const [copied, setCopied] = useState(false); + + const apply = (p: (typeof PRESETS)[0]) => { + setMethod(p.method); + setEndpoint(p.endpoint); + setBody(p.body); + }; + + const send = async () => { + setLoading(true); + setResponse(null); + setResponseTime(null); + const start = Date.now(); + try { + const headers: Record = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const opt: RequestInit = { method, headers }; + if (method !== "GET" && body.trim()) { + try { + JSON.parse(body); + opt.body = body; + } catch { + toast.error("JSON 형식 오류"); + setLoading(false); + return; + } + } + const res = await fetch(`${baseUrl}${endpoint}`, opt); + const elapsed = Date.now() - start; + setResponseTime(elapsed); + const ct = res.headers.get("content-type"); + const data = ct?.includes("json") ? await res.json() : await res.text(); + setResponse({ status: res.status, statusText: res.statusText, data }); + toast.success(res.ok ? `성공 ${res.status}` : `실패 ${res.status}`); + } catch (e) { + setResponseTime(Date.now() - start); + setResponse({ status: 0, statusText: "Network Error", data: { error: String(e) } }); + toast.error("네트워크 오류"); + } finally { + setLoading(false); + } + }; + + const copyRes = () => { + navigator.clipboard.writeText(JSON.stringify(response?.data, null, 2)); + setCopied(true); + toast.success("복사됨"); + setTimeout(() => setCopied(false), 2000); + }; + + const statusV = (s: number) => (s >= 200 && s < 300 ? "success" : s >= 400 ? "destructive" : "secondary"); + + return ( +
+
+

API 테스트

+

API를 직접 호출하여 테스트합니다.

+
+
+
+ + + API 설정 + + +
+ + setBaseUrl(e.target.value)} /> +
+
+ + setApiKey(e.target.value)} placeholder="sk-xxx" /> +
+
+
+ + + 빠른 선택 + + +
+ {PRESETS.map((p, i) => ( + + ))} +
+
+
+ + + 요청 + + +
+ + setEndpoint(e.target.value)} className="flex-1" /> +
+ {method !== "GET" && ( +
+ +